dantinox.trainer — Training API Reference
This page is the complete reference for the legacy Trainer class in
dantinox/trainer.py — the Config-driven trainer backing the
dantinox train CLI / YAML workflow (Trainer(config).fit(data_path, ...)).
This is not
dx.Trainer. The symbol re-exported at the top level (from dantinox import Trainer, used in the paper’s API — see Figure 3 / Configuration Reference) resolves todantinox.training.trainer.Trainer, a different, paradigm-agnostic class with the signatureTrainer(paradigm, training_config).fit(data_source, ...), used asdx.Trainer(dx.Paradigm(dx.ModelConfig(...)), dx.TrainingConfig(...)).fit(dataset). It is driven entirely throughParadigm.loss_fn/generate/build_modelrather than themodel_typedispatch described below — see Paradigm System for its design and Configuration Reference forTrainingConfigfields. The two classes share the same name but are not interchangeable; this page only covers the legacy one.
Every parameter, every internal step, and every helper function of the legacy trainer is explained here in detail.
Overview
Trainer is a single class that handles the full training lifecycle for all
three DantinoX model families: autoregressive (Transformer), masked-diffusion
(DiffusionTransformer, an alias of Transformer), and continuous
flow-matching (FlowMatchingTransformer; ELFTransformer is a deprecated
alias of the same class).
Trainer.fit(data_path)
│
├─ 1. Dataset loading → tokenised .npy cache
├─ 2. max_train_tokens cap → fixed compute budget
├─ 3. 90 / 10 train/val split
├─ 4. _build_optimizer → schedule + gradient clipping
├─ 5. _create_model → Transformer | DiffusionTransformer | FlowMatchingTransformer
├─ 6. _cast_params(bfloat16) → before JIT compilation
├─ 7. nnx.Optimizer(wrt=...) → LoRA vs full-param training
├─ 8. make_mesh / shard_batch → multi-GPU data parallelism
├─ 9. resume logic → training_cursor.json + model_weights.msgpack
├─ 10. JIT train_step → AR | Diffusion | Continuous Flow-Matching dispatch
├─ 11. gradient accumulation → acc pattern, division by grad_accum
├─ 12. estimate_loss → stratified t for diffusion
├─ 13. checkpointing → best_model_weights.msgpack
└─ 14. early stopping → patience counter
Trainer.__init__(config)
trainer = Trainer(config)
__init__ stores the Config instance and does nothing else — no model
allocation, no JIT compilation, no data loading. All of that happens inside
fit().
config — what it is and what it stores
config is a core.config.Config dataclass (the monolithic flat config kept
for backward compatibility). It holds every knob for both the model architecture
and the training procedure in one flat namespace. The relevant fields are:
Architecture fields (passed to _create_model):
Field |
Type |
Default |
Meaning |
|---|---|---|---|
|
|
|
Hidden dimension of the transformer |
|
|
|
Number of query attention heads |
|
|
|
Dimension per head; must satisfy |
|
|
|
Number of transformer blocks |
|
|
|
Vocabulary size. Auto-set from the tokenizer inside |
|
|
|
Maximum sequence length |
|
|
|
KV heads for GQA. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Use SwiGLU activation in FFN |
|
|
|
Wrap blocks with |
|
|
|
Enable LoRA adapter training |
|
|
|
LoRA rank |
|
|
|
LoRA scaling factor |
Training fields (used inside fit):
Field |
Type |
Default |
Meaning |
|---|---|---|---|
|
|
|
Peak learning rate |
|
|
|
Total batch size per optimizer step |
|
|
|
Gradient accumulation steps |
|
|
|
Training epochs |
|
|
|
Linear warmup length; capped at 30 % of |
|
|
|
|
|
|
|
Optimizer name (see table below) |
|
|
|
Global gradient norm clipping; |
|
|
|
Early-stopping evaluations without improvement; |
|
|
|
Evaluation batches per |
|
|
|
Cast parameters to bfloat16 before JIT |
|
|
|
PRNG seed for model init, data batching, and diffusion noise |
|
|
|
GPUs to use; |
|
|
|
Steps between resume checkpoints |
|
|
|
Token cap on the dataset |
Trainer.fit(...)
run_dir = trainer.fit(
data_path, # (1)!
*,
run_dir=None, # (2)!
wandb_project=None, # (3)!
resume=False, # (4)!
)
Path to a UTF-8 text file, or
Noneto fall back toconfig.dataset_name(which can be a local path or a HuggingFace dataset id whenconfig.dataset_source == "huggingface").Directory where checkpoints and logs are written. When omitted,
fitcreatesruns/run_YYYYMMDD_HHMMSS/automatically.If not
None, metrics are streamed to Weights & Biases under this project name. Requireswandbto be installed and authenticated.If
Trueand bothtraining_cursor.jsonandmodel_weights.msgpackexist insiderun_dir, training resumes from the saved step. Optimizer state is not preserved — the optimizer is re-initialised from scratch.
Returns the run_dir string so callers can locate the checkpoint:
run_dir = trainer.fit("data/wiki.txt")
print("Best checkpoint:", run_dir + "/best_model_weights.msgpack")
Step 1 — Dataset loading and the tokenised-array cache
The first time a (dataset_name, dataset_config, tokenizer_type) combination
is used, downloading and tokenising a large corpus can take tens of seconds or
more. To avoid repeating this work on every run, fit builds a two-file cache
under the data/ directory that lives one level above run_dir:
data/
<dataset_slug>_<config>_<tok_type>.npy ← token ID array, dtype int32
<dataset_slug>_<config>_<tok_type>.json ← serialised tokenizer
Cache key. The key is formed by concatenating:
f"{config.dataset_name.replace('/', '_')}_{config.dataset_config or 'default'}_{config.tokenizer_type}"
For example, wikitext_wikitext-103-raw-v1_bpe or corpus_default_char.
Fast path (cache hit): fit calls load_tokenizer_from_file on the cached
JSON and loads the .npy with numpy.load. This typically takes under two
seconds regardless of corpus size.
Slow path (cache miss):
If
config.dataset_source == "huggingface", HuggingFacedatasetsis imported andload_datasetis called withconfig.dataset_name,config.dataset_config,config.dataset_split, andconfig.dataset_text_field. Whenconfig.streaming=Truethe dataset is iterated as anIterableDatasetand materialised to a single string; otherwise the full split is loaded.If
dataset_source == "local", the file atdata_path(orconfig.dataset_name) is opened directly.The raw text is normalised with
_format_text(groups lines into 3-line paragraphs, removes blank lines).A tokenizer is created with
get_tokenizer(config.tokenizer_type):char: character-level; vocabulary is trained from the text.bpe: byte-pair encoding; vocabulary is trained toconfig.vocab_size.t5: pre-trained SentencePiece tokenizer from the T5 family; no training needed.
Token IDs are written to the
.npycache and the tokenizer is saved to the.jsoncache.
After loading, config.vocab_size is updated in place from tokenizer.vocab_size.
!!! tip “Why the cache exists”
Re-tokenising WikiText-103 (~100M chars) takes ~60 s with a BPE tokenizer.
With the cache it takes ~2 s. The cache persists across runs and across
different run_dir values as long as the dataset and tokenizer type are
the same.
Step 2 — max_train_tokens cap
_max_tok = getattr(config, "max_train_tokens", 10_000_000)
if _max_tok > 0 and len(full_data) > _max_tok:
full_data = full_data[:_max_tok]
This is a simple prefix slice. The default cap is 10 million tokens, which
corresponds to roughly 822 optimizer steps at batch_size=128,
max_context=512, grad_accum=16 on WikiText-103. On two A100s this takes
about seven minutes — a sensible budget for an exploratory run.
Set max_train_tokens=0 or a very large number to disable the cap and use
the entire corpus.
Step 3 — Train / validation split
n = int(0.9 * len(full_data))
train_data, val_data = full_data[:n], full_data[n:]
A deterministic 90/10 split on the token array. No shuffling is performed at
this stage — shuffling happens implicitly through the random batch sampling in
get_batch (which draws random starting positions at each step).
The validation set is the last 10 % of the token sequence. For most natural language corpora this is a representative held-out slice.
Step 4 — Optimizer and schedule construction
fit calls _build_optimizer(config, total_steps) which in turn calls
_build_schedule(config, total_steps).
_build_schedule
def _build_schedule(config, total_steps):
warmup_steps = min(
getattr(config, "warmup_steps", int(total_steps * 0.1)),
int(total_steps * 0.3), # hard cap at 30 %
)
peak = config.lr
end = peak * 0.01 # final LR = 1 % of peak
...
The warmup length is config.warmup_steps, but it is clamped to a maximum of
30 % of total_steps so that very long warmups do not dominate short runs.
All schedules share the same end value: 1 % of the peak learning rate.
=== “cosine”
Standard warmup followed by cosine decay, implemented by
`optax.warmup_cosine_decay_schedule`.
```
lr
peak ─────────────╮
╲___cosine decay___
end ─────────────────────────────────── step
warmup decay_steps
```
=== “linear”
Linear warmup from 0 → peak, then linear decay from peak → end.
```python
warmup = optax.linear_schedule(0.0, peak, warmup_steps)
decay = optax.linear_schedule(peak, end, total_steps - warmup_steps)
return optax.join_schedules([warmup, decay], [warmup_steps])
```
=== “constant”
Linear warmup from 0 → peak, then a flat plateau at `peak` for the
remainder of training. Useful for short experiments where you want to
stay at maximum learning rate.
=== “wsd”
Warmup → Stable → Decay, a three-phase schedule described in the WSD paper:
- Warmup: linear from 0 → peak over `warmup_steps`.
- Stable: constant at peak for 40 % of `total_steps`.
- Decay: cosine from peak → end over the remaining steps.
```
lr
peak ──────────────╮
warmup stable╰──cosine decay──
end ───────────────────────────────── step
```
WSD is particularly effective for large models because the stable plateau
lets the optimizer converge at peak LR before the final cool-down.
_build_optimizer
The schedule is wrapped in one of five optimizer variants:
|
Optax call |
Notes |
|---|---|---|
|
|
AdamW with default weight decay 0.1; standard choice |
|
|
Adam without weight decay |
|
|
Memory-efficient; no second-moment per-parameter; good for very large models on memory-constrained devices |
|
|
Sign-gradient update; requires a lower LR than AdamW (typically 3–10× lower) |
|
|
Applies Newton-Schulz orthogonalization to 2-D parameter gradients; falls back to Adam for biases and norms; available since optax 0.2.6 |
Gradient clipping. When config.grad_clip > 0 (default 1.0), the
optimizer is wrapped with optax.clip_by_global_norm:
if grad_clip > 0:
return optax.chain(optax.clip_by_global_norm(grad_clip), base_opt)
The chain applies clipping before the optimizer update, which is the
standard convention. When grad_clip=0.0, no clipping is applied.
Step 5 — Model construction
model = _create_model(config, rngs)
_create_model routes on config.model_type:
|
Class instantiated |
|---|---|
|
|
|
|
|
|
For the flow-matching paradigm, config.to_flow_config() first converts the
flat Config into a FlowMatchingConfig dataclass that contains only the
flow-relevant fields. (core.elf.ELFTransformer and config.to_elf_config()
are deprecated aliases of the same class/method.)
Step 6 — bfloat16 casting (_cast_params)
if getattr(config, "use_bf16", False):
_cast_params(model, jnp.bfloat16)
_cast_params extracts all nnx.Param variables from the model, casts every
floating-point array to bfloat16, and writes them back with nnx.update:
def _cast_params(model, dtype):
params = nnx.state(model, nnx.Param)
nnx.update(
model,
jax.tree_util.tree_map(
lambda x: x.astype(dtype) if jnp.issubdtype(x.dtype, jnp.floating) else x,
params,
),
)
Why before JIT? JAX traces the computation graph at the first call to a
@nnx.jit-decorated function and specialises on the concrete dtypes of the
inputs. If the cast happened inside the JIT boundary, XLA would see mixed
float32/bfloat16 and insert extra conversion ops. Casting before compilation
ensures the entire compiled graph operates uniformly in bfloat16, maximising
throughput on hardware with fast bfloat16 units (A100, H100, TPU).
Step 7 — LoRA vs full-parameter training
wrt_type = LoRAParam if getattr(config, "use_lora", False) else nnx.Param
optimizer = nnx.Optimizer(model, tx, wrt=wrt_type)
nnx.Optimizer(model, tx, wrt=T) only tracks and updates variables of type
T. This is the key mechanism that makes LoRA work at the type system level:
Full-param training (
wrt=nnx.Param): everynnx.Paramin the model receives gradient updates. This includes all weights in all layers.LoRA training (
wrt=LoRAParam): onlyLoRAParamvariables — the adapter matrices A and B inside everyLoRALinear— receive updates. The base weights (stored asnnx.ParaminsideLoRALinear.base) are frozen; they still participate in the forward pass but XLA never computes or accumulates gradients for them during the backward pass.
LoRAParam is defined in core/lora.py as a trivial subclass of nnx.Variable:
class LoRAParam(nnx.Variable):
"""Trainable LoRA variable — distinct type so base nnx.Param weights stay frozen."""
pass
The type distinction is the entire freezing mechanism. No masking, no
stop_gradient, no manual gradient zeroing.
!!! note “LoRA targets”
config.lora_targets controls which layers use LoRALinear instead of
nnx.Linear:
- "attention" (default): only the QKV and output projections.
- "mlp": only the FFN up/down projections.
- "all": both attention and FFN layers.
Step 8 — Multi-GPU data parallelism
n_dev_cfg = getattr(config, "n_devices", 0)
n_local = len(jax.local_devices())
use_multi_gpu = (n_dev_cfg != 1) and n_local > 1
mesh = make_mesh(n_dev_cfg) if use_multi_gpu else None
When n_devices != 1 and more than one local device is visible, DantinoX
creates a data-parallel mesh via core.sharding.make_mesh. A jax.sharding.Mesh
is a logical grid of devices; here it is a 1-D mesh with a single "data" axis.
Batch divisibility requirement. The total batch_size must be exactly
divisible by the number of devices. fit raises ConfigError if this is not
satisfied:
if config.batch_size % n_dev != 0:
raise ConfigError(
f"batch_size ({config.batch_size}) must be divisible by "
f"n_devices ({n_dev}) for data-parallel training."
)
This is required because shard_batch splits the batch tensor along the first
axis into n_dev equal shards, one per device.
After building the mesh, model, optimizer, and metrics state are replicated across all devices once:
state = replicate(nnx.state((model, optimizer, metrics)), mesh)
nnx.update((model, optimizer, metrics), state)
At each training step, shard_batch(x, mesh) distributes the batch tensor
so each device sees only its own shard. Because the model is replicated, each
device runs the same computation on a different slice of the batch. Gradients
are all-reduced across devices inside the JIT-compiled step by JAX’s SPMD
machinery.
Set n_devices=0 to automatically use all available GPUs, or n_devices=1
to force single-device mode even when multiple GPUs are present.
Step 9 — Resume logic
When resume=True, fit looks for two files in run_dir:
File |
Contents |
|---|---|
|
|
|
Full parameter state at step N, msgpack-serialised |
if resume and os.path.exists(cursor_path) and os.path.exists(resume_weights):
with open(cursor_path) as f:
cursor = json.load(f)
start_step = int(cursor.get("step", 0)) + 1
with open(resume_weights, "rb") as f:
state_dict = msgpack.unpackb(f.read(), ext_hook=_msgpack_ext_unpack, ...)
nnx.update(model, state_dict)
Training then starts from start_step instead of 0. Note that the
optimizer state is not restored — the optimizer starts fresh. This means
Adam’s first and second moment estimates are zero at the start of resumed
training, which causes a brief “warmup” effect in the loss even if the LR
schedule does not have a warmup. This is a known limitation.
The training_cursor.json file is deleted at the end of a successful run
so a completed run is not confused with an interrupted one.
Step 10 — The three JIT-compiled train_step functions
fit defines and JIT-compiles a train_step function whose implementation
differs across the three model families. The dispatch is a Python if/elif/else
block, so only one train_step is compiled per fit call.
=== “Autoregressive (AR)”
```python
@nnx.jit
def train_step(model, opt, metrics, full_x, full_y, _unused_key=None):
xs = full_x.reshape(config.grad_accum, micro_bs, -1)
ys = full_y.reshape(config.grad_accum, micro_bs, -1)
def _loss(model, x, y):
out = model(x)
loss = compute_loss(out.logits, y)
if getattr(model, "use_moe", False):
loss = loss + model.alpha_balance * out.aux_loss
return loss, out.aux_loss
...
```
The input is a shifted token pair `(x, y)` where `y = x[1:]` (next-token
prediction). `compute_loss` is cross-entropy over the vocabulary.
For MoE models, the load-balancing auxiliary loss is added to the total
loss with weight `model.alpha_balance`.
=== “Diffusion”
```python
@nnx.jit
def train_step(model, opt, metrics, full_x, _unused_y, key):
xs = full_x.reshape(config.grad_accum, micro_bs, -1)
def _loss(model, x, key):
key, sub_t = jax.random.split(key)
t_batch = jax.random.uniform(sub_t, (micro_bs,), minval=_t_min, maxval=1.0)
key, sub_c = jax.random.split(key)
x_t = corrupt(x, t_batch, sub_c, _noise_schedule, config.mask_token_id)
out = model(x_t, deterministic=False)
loss = masked_cross_entropy(
out.logits, x, x_t, config.mask_token_id,
t_float=t_batch, aux_loss=out.aux_loss,
alpha_balance=model.alpha_balance,
)
return loss, out.aux_loss
...
```
Follows the LLaDA formulation (arXiv:2502.09992). At each step:
1. A per-sequence `t` is sampled from `U[t_min, 1]`.
2. `corrupt` replaces each token with the mask token with probability
`p_mask(t)` (linear schedule: `p_mask = t`).
3. The time-free model predicts the original token for every masked position.
4. The loss is `(1/t) × cross_entropy_on_masked_tokens` (ELBO weight, Eq. 3
of the LLaDA paper).
`t_min` is set to `max(1/max_context, 0.05)`. The lower bound of 0.05
prevents extreme gradient variance: at `t = 1/L ≈ 0.002` (one masked token
per sequence), the `1/t` weight would be ~500, completely dominating the
gradient. Flooring at 0.05 corresponds to approximately 26 masked tokens per
512-token sequence, keeping the gradient well-conditioned.
=== “Continuous Flow-Matching”
```python
@nnx.jit
def train_step(model, opt, metrics, full_emb, full_x, key):
E = full_emb.shape[-1]
embs = full_emb.reshape(config.grad_accum, micro_bs, -1, E)
xs = full_x.reshape(config.grad_accum, micro_bs, -1)
def _loss(model, emb_i, x_i, key):
embeddings = model.encode(emb_i) # channel-wise normalise
loss, aux = flow_loss(model, embeddings, x_i, key, _flow_config)
return loss, aux["den_loss"]
...
```
(`elf_loss` is a deprecated alias of `flow_loss`.)
The flow-matching step takes a *pre-computed T5 embedding* `full_emb` instead of raw
tokens. T5 is a large encoder-only model that is kept *outside* JIT — its
forward pass runs on a separate XLA computation (line 665 of trainer.py:
`emb = _t5_encoder.encode(x)` before the JIT call). This avoids retracing
T5's large computation graph every step and allows it to run independently.
Before training starts, `fit` computes channel-wise mean/std statistics from
four batches and stores them in `model.embedder.emb_mean` and
`model.embedder.emb_std`. The `model.encode(emb_i)` call inside the JIT
step uses these statistics to normalise each channel, ensuring the
flow-matching loss is on a consistent scale.
Step 11 — Gradient accumulation
All three train_step variants share the same accumulation pattern:
grad_fn = nnx.value_and_grad(_loss, argnums=DiffState(0, _wrt), has_aux=True)
acc = jax.tree_util.tree_map(jnp.zeros_like, nnx.state(model, _wrt)) # (1)!
total_loss = jnp.array(0.0)
for i in range(config.grad_accum): # (2)!
(loss, aux), grads = grad_fn(model, ...)
acc = jax.tree_util.tree_map(
lambda a, g: a + g / config.grad_accum, # (3)!
acc, grads
)
total_loss += loss / config.grad_accum # (4)!
opt.update(model, acc) # (5)!
accis a pytree of zeros with the same structure as the trainable parameters. It acts as the gradient accumulation buffer.The loop runs
grad_accumtimes, each time on a different micro-batch of sizemicro_bs = batch_size // grad_accum.Each micro-batch gradient is divided by
grad_accumbefore being added toacc. After all iterations,accholds the average gradient over the full logical batch.The loss is similarly averaged. The reported
total_lossis the true mean loss over the full batch.A single
opt.updatecall is made at the end with the accumulated gradient. This is equivalent to one forward/backward pass over a batch of sizebatch_size, but uses onlymicro_bsactivation memory at a time.
Because the loop is inside @nnx.jit, XLA unrolls it at compile time. All
grad_accum forward/backward graphs coexist in the compiled program
simultaneously. When gradient_checkpointing=True, nnx.remat wraps each
block’s internals and discards intermediate activations, recomputing them on
the backward pass — this keeps peak VRAM manageable even with large
grad_accum values.
Step 12 — Evaluation loop (estimate_loss)
estimate_loss is called every 500 training steps. It returns a dict with
"train", "val", "train_bal", and "val_bal" keys.
=== “Autoregressive”
Plain cross-entropy evaluated on `eval_iters` random batches of size 1
from both splits:
```python
for split, d in [("train", train_data), ("val", val_data)]:
for _ in range(config.eval_iters):
x, y = get_batch(d, 1, config.max_context, sub)
loss_val, b, _ = eval_step(model, x, y)
```
=== “Diffusion — stratified t”
The diffusion evaluation uses stratified sampling of the noise level `t`:
```python
n = config.eval_iters
t_low = _t_min # same floor as training: max(1/max_context, 0.05)
t_strata = [
jnp.full((eval_bs,), t_low + (1.0 - t_low) * (i + 0.5) / n)
for i in range(n)
]
```
Each of the `n` evaluation batches is evaluated at a different, evenly
spaced noise level. This ensures coverage of the entire `[t_min, 1]`
range. The `t_min` floor is the same as training so that val and train
losses are on the same scale — using `t` values below `t_min` during
evaluation would inflate val loss because the model was never trained
at those noise levels and the `1/t` weight is very large there.
=== “Continuous Flow-Matching”
T5 embeddings are computed outside JIT (`emb = _t5_encoder.encode(x)`)
and then passed to `eval_step`. The flow-matching loss is averaged over
`config.eval_iters` batches.
Step 13 — Checkpointing
Two checkpoint files are managed:
File |
When written |
Contents |
|---|---|---|
|
Every time |
Best parameter state seen so far |
|
Every |
Current parameter state for resuming |
|
Together with |
|
Only the best checkpoint is saved in best_model_weights.msgpack. There is
no rolling window of recent checkpoints; at any point during training the file
contains the single best set of weights seen. This minimises disk usage while
ensuring the best model is always recoverable.
The resume checkpoint (model_weights.msgpack + training_cursor.json) is a
separate pair written periodically so that interrupted runs can be restarted
without losing all progress.
msgpack format. _save_weights calls flax.serialization.msgpack_serialize
on a plain Python dict of numpy arrays (the output of nnx.state(model, nnx.Param).to_pure_dict()). This is the native Flax NNX serialisation format.
To load a checkpoint manually:
import msgpack
from flax import nnx
from flax.serialization import _msgpack_ext_unpack
with open("runs/my_run/best_model_weights.msgpack", "rb") as f:
state_dict = msgpack.unpackb(
f.read(), ext_hook=_msgpack_ext_unpack, strict_map_key=False
)
nnx.update(model, state_dict)
Step 14 — Early stopping
patience = getattr(config, "patience", 0)
best_val_loss = float("inf")
no_improve = 0
if val_loss < best_val_loss:
best_val_loss = val_loss
no_improve = 0
_save_weights(model, best_weights_path)
else:
no_improve += 1
if patience > 0 and no_improve >= patience:
break
patience counts the number of consecutive evaluation checkpoints
(every 500 steps) without improvement in val_loss. When no_improve
reaches patience, training stops. Set patience=0 (the default) to
disable early stopping and always run for the full total_steps.
Weights & Biases logging
if wandb_project is not None:
wandb.log({"train_loss": losses["train"], "val_loss": val_loss, "step": step})
Three scalars are logged at every evaluation point: train_loss, val_loss,
and step. The run is initialised with wandb.init(project=wandb_project, config=config.to_dict()) so all hyperparameters appear in the W&B run
configuration panel.
Trainer.find_lr(data_path, min_lr, max_lr, num_steps, smoothing)
Implements the LR Range Test (Smith 2015, arXiv:1506.01186). The idea is to
train for a short period while exponentially increasing the learning rate from
min_lr to max_lr, observe where the loss starts to diverge, and pick the
LR at the point of steepest loss descent as a good starting point for full
training.
suggested_lr, lr_history, loss_history = trainer.find_lr(
"data/corpus.txt",
min_lr=1e-7, # (1)!
max_lr=1.0, # (2)!
num_steps=100, # (3)!
smoothing=0.9, # (4)!
)
Starting learning rate for the sweep (default
1e-7).Maximum learning rate (default
1.0). The sweep is exponential so evenmax_lr=1.0is reached smoothly.Total number of training steps (default
100). More steps give a smoother curve; fewer steps are faster.Exponential moving average factor for loss smoothing (default
0.9). Higher values produce smoother curves but delay responsiveness.
Exponential LR sweep
The learning rate at step s is:
This means lr(0) = min_lr and lr(num_steps - 1) = max_lr.
log_multiplier = math.log(max_lr / min_lr) / max(1, num_steps - 1)
def _lr_fn(step):
return min_lr * jnp.exp(step * log_multiplier)
Smoothed loss and bias correction
The raw loss at each step is noisy. find_lr applies an exponential moving
average (EMA) and corrects the bias introduced by the EMA being initialised at
zero:
smooth_loss = smoothing * smooth_loss + (1 - smoothing) * loss_val
debiased = smooth_loss / (1 - smoothing ** (step + 1))
This is the same formula used in Adam’s bias-corrected moment estimates.
Early stopping criterion
if debiased > 4 * best_loss:
break
If the bias-corrected loss exceeds four times the best loss seen so far, the learning rate has clearly diverged and the sweep is stopped early. This prevents wasting time on LR values that are far too large.
Suggested LR selection
slopes = [loss_history[i+1] - loss_history[i] for i in range(len(loss_history) - 1)]
suggested_lr = lr_history[min(range(len(slopes)), key=lambda i: slopes[i])]
The suggested LR is the learning rate at the step with the steepest negative
slope in the smoothed loss curve — i.e., where the loss is falling fastest.
This is the standard heuristic from the Smith paper. A common rule of thumb is
to use suggested_lr / 10 as the peak LR for actual training to maintain a
safety margin.
Returns
find_lr returns a 3-tuple:
Element |
Type |
Description |
|---|---|---|
|
|
LR at the point of steepest descent |
|
|
LR value at each step |
|
|
Bias-corrected smoothed loss at each step |
Plot loss_history against lr_history (log scale on the x-axis) to visually
inspect the curve and validate the suggestion.
!!! warning “find_lr uses only Transformer”
The current implementation always instantiates a Transformer (AR model)
regardless of config.model_type. Use find_lr to tune the LR for AR runs
and use the result as a starting point for diffusion/flow-matching runs with appropriate
scaling.
Helper functions
_build_schedule(config, total_steps)
Described in Step 4 above.
Returns an optax.Schedule callable lr_fn(step) -> float.
_build_optimizer(config, total_steps)
Described in Step 4 above.
Returns an optax.GradientTransformation.
_model_summary(model, config, optimizer)
Computes a memory and parameter count summary and writes it to
model_summary.json in run_dir.
_save_weights(model, path)
def _save_weights(model, path):
state_dict = nnx.state(model, nnx.Param).to_pure_dict()
with open(path, "wb") as f:
f.write(flax.serialization.msgpack_serialize(state_dict))
Extracts the nnx.Param state tree, converts it to a plain Python dict of
numpy arrays, and serialises it with Flax’s msgpack serialiser. The file is
a self-contained binary that can be deserialized without access to the Python
model class.
model_summary.json — field reference
Written to <run_dir>/model_summary.json before training begins. Example:
{
"total_params_M": 124.44,
"dtype": "bfloat16",
"weights_mem_MB": 248.88,
"optimizer_mem_MB": 497.76,
"est_activations_MB": 1342.18
}
Field |
Formula |
Meaning |
|---|---|---|
|
|
Total number of trainable parameters in millions |
|
|
Data type of stored weights |
|
|
Memory consumed by model weights; |
|
|
Memory for optimizer state (e.g. Adam’s two moment tensors double the parameter count) |
|
|
Estimated activation memory |
Activation memory formula explained.
act = micro_bs * config.max_context * config.dim * config.num_blocks * 8 * bpp * grad_accum
The factor 8 is an empirical constant that accounts for the multiple
intermediate tensors stored during a transformer forward pass per block: the
pre-norm activations, QKV projections, attention weights, context vector, FFN
input/output, and residual connections. The multiplication by grad_accum
reflects the fact that the loop inside @nnx.jit is fully unrolled by XLA —
all grad_accum micro-batch activation graphs coexist simultaneously in the
compiled program. When gradient_checkpointing=True, block internals are
recomputed on the backward pass, reducing the effective activation footprint
significantly.
!!! note “Estimates, not exact figures”
est_activations_MB is a rough upper bound. Actual VRAM usage will
differ because of XLA buffer reuse, operator fusion, and the effect of
gradient_checkpointing. Use profiling tools (e.g. jax.profiler) for
precise measurements.
Files written to run_dir
File |
Written when |
Contents |
|---|---|---|
|
Immediately at start |
Exact |
|
After tokenizer creation or cache copy |
Serialised tokenizer for inference |
|
Before training loop |
Parameter count, memory estimates |
|
Every 500 steps |
|
|
On every new best val loss |
Best parameter state |
|
Every |
Resume checkpoint |
|
Together with |
|
training_cursor.json is removed at the end of a successful run.
See also
Configuration Reference — all
ConfigfieldsArchitecture: Core Layers — attention, FFN, LoRA internals
CLI Reference —
dantinox trainanddantinox find-lrCookbook — end-to-end training recipes