7. Language modeling (decoder-only)¶
NNx ships a TinyStories-class decoder-only Transformer alongside the GNN and
FeedFwd architectures. Use it for small autoregressive experiments. It
provides byte-pair encoded tokenization, RoPE positional encoding, SwiGLU FFN,
RMSNorm, tied input/output embeddings, and an autoregressive generate() with
the standard sampling knobs.
This is intentionally not a production LLM stack — there's no FlashAttention v3, no tensor parallelism, no multi-GPU sharding. The path is sized to "train end-to-end on a laptop and inspect or publish the resulting NNx model." It is not a stock LLaMA architecture. Production-scale training is out of scope.
1. Public surface¶
| Symbol | Notes |
|---|---|
nnx.Nets.TRANSFORMER |
Enum variant; dispatches to TransformerNN via NNModelParams.net. |
nnx.TransformerNN |
nn.Module — decoder-only stack: token embed + N blocks + final RMSNorm + tied LM head. |
nnx.NNTransformerParams |
Frozen dataclass (subclass of NNParams) — vocab_size, n_layers, n_heads, d_model, ffn_mult, max_seq_len, rope_base, tie_embeddings, attn_dropout, resid_dropout. Every optional field omits itself from state() when at its default. Construction validates the integer counts and head width; see the rules in §3. |
nnx.NNTokenizerParams |
Wraps tokenizers.Tokenizer; state() returns {"path": "<tokenizer.json>"}. Available when thekaveh-nnx[lm] is installed. |
nnx.train_bpe(...) |
Quick BPE training helper (Whitespace pre-tokenizer + BPE + BpeTrainer). |
nnx.GenerativeNNModel |
NNModel subclass adding generate(prompt, max_new_tokens, temperature, top_k, top_p, repetition_penalty, stop, seed, use_cache, logits_chain, on_token, logits_pipeline). |
nnx.{TemperatureScaling, TopKFilter, TopPFilter, RepetitionPenalty, apply_chain} |
LogitsProcessor chain — same shape as HF transformers' LogitsProcessorList. |
nnx.NNTransformerParamsBuilder |
Fluent variant-gated builder for NNTransformerParams — hides the dead parent-NNParams kwargs (hidden_dims / activation / dropout_prob); reach via NNTransformerParams.builder(). |
nnx.LogitsChain |
Frozen-dataclass wrapper around list[LogitsProcessor] with .apply(logits, token_history) -> Tensor. Pass via GenerativeNNModel.generate(logits_chain=...). |
nnx.LogitsChainBuilder |
Fluent builder for LogitsChain — accepts processor calls in any order, sorts standard processors into NNx's canonical order at .build(). Reach via LogitsChain.builder(). |
nnx.OrderedLogitsPipeline |
Immutable stages (LogitsStage specs, CustomStage callables) run in exactly their declared order. append / prepend return new pipelines; versioned state() / from_state() (custom stages via a registered LogitsStageCodec). Pass via generate(logits_pipeline=...) — see §5.1. |
2. Install¶
The LM path is opt-in:
pip install "thekaveh-nnx[lm]" # adds tokenizers>=0.20, datasets>=2.20
tokenizers is the HF Rust BPE / WordPiece tokenizer. datasets is only
used by the example for downloading TinyStories — the rest of the LM path
runs without it.
3. Quickstart¶
import torch
from torch.utils.data import DataLoader
from nnx import (
Devices, Losses, Nets, NNModelParams, NNOptimParams, NNTrainParams,
NNTransformerParams, NNTokenizerParams, GenerativeNNModel, set_seed, train_bpe,
)
set_seed(0)
# 1. Train a tiny BPE tokenizer.
corpus = ["the quick brown fox", "the lazy dog", "once upon a time"]
tk = train_bpe(files=None, texts=corpus, vocab_size=128,
special_tokens=["<unk>", "<pad>", "<bos>", "<eos>"])
tokenizer = NNTokenizerParams.of(tokenizer=tk, path="artifacts/tok.json")
# 2. Build the model. NNTransformerParams subclasses NNParams so
# the rest of the NNModel machinery accepts it unchanged.
net_params = NNTransformerParams(
input_dim=tokenizer.vocab_size,
output_dim=tokenizer.vocab_size,
dropout_prob=0.0,
vocab_size=tokenizer.vocab_size,
n_layers=4, n_heads=4, d_model=128,
ffn_mult=4, max_seq_len=64,
)
model_params = NNModelParams(net=Nets.TRANSFORMER, device=Devices.CPU,
loss=Losses.CROSS_ENTROPY)
model = GenerativeNNModel(net_params=net_params, params=model_params,
tokenizer=tokenizer)
# 3. Train with the causal-LM task: (inputs, targets) windows are already
# aligned, so nothing is shifted again; see examples/11_tinystories_lm.py.
from torch.utils.data import TensorDataset
from nnx.lm_tasks import CausalLMTask
ids = [i for text in corpus for i in tk.encode(text).ids]
cut = len(ids) - max(6, len(ids) // 5) # split the token stream first: no shared tokens
def windows(stream):
return torch.tensor([stream[i : i + 5] for i in range(len(stream) - 4)])
train, val = windows(ids[:cut]), windows(ids[cut:])
train_loader = DataLoader(TensorDataset(train[:, :-1], train[:, 1:]), batch_size=4, shuffle=True)
val_loader = DataLoader(TensorDataset(val[:, :-1], val[:, 1:]), batch_size=4)
task = CausalLMTask(vocab_size=tokenizer.vocab_size, alignment="pre_shifted")
run = model.train(
params=NNTrainParams(n_epochs=2, train_loader=train_loader, val_loader=val_loader,
optim=NNOptimParams.builder().adam(max_lr=3e-4).build()),
objective=task.objective(), # token CE over valid tokens / window valid-token count
eval_step_fn=task.eval_step(), # unsmoothed NLL, perplexity = exp(NLL), token accuracy
)
# 4. Generate.
out = model.generate(
prompt="once upon a time",
max_new_tokens=32,
temperature=0.8,
top_k=20,
seed=42, # reproducible sampling
)
print(out)
Alternative: variant-aware Builder
Same config via NNTransformerParams.builder(). The dead parent
fields (hidden_dims, activation, dropout_prob) are hidden.
.layers(...) validates at call-time rather than waiting for
__post_init__, with the same rules the direct constructor applies:
n,headsandd_modelmust be positive integers. NumPy integers are normalized toint;128.0, booleans and strings are rejected before any arithmetic.d_model % heads == 0is required.- The head width
d_model / headsmust be even because RoPE rotates channel pairs. vocab_size,ffn_multandmax_seq_lenare positive integer counts too, with the same rules, checked by the constructor.
net_params = (
NNTransformerParams.builder()
.vocab(tokenizer.vocab_size)
.layers(n=4, heads=4, d_model=128)
.context(max_seq_len=64)
.build()
)
Both paths produce identical NNTransformerParams instances and
round-trip through state() / from_state() interchangeably.
To vary an existing config, such as a longer context for a second run,
rebuild a builder from it instead of retyping the chain:
NNTransformerParamsBuilder.from_params(net_params).context(max_seq_len=256, rope_base=net_params.rope_base).build().
from_params carries every field, including inherited NNParams
fields no setter exposes. .copy() branches a builder in progress
(see Concepts §2.4).
Setters keep their last-call-wins rules on a rebuilt builder.
.context() sets max_seq_len and rope_base; an omitted
rope_base resets it to 10000.0. .dropout() sets both rates. Pass a
carried value again to keep it.
4. When to use what¶
4.1. Greedy decoding (deterministic)¶
model.generate(prompt="...", temperature=0.0)
temperature=0 short-circuits to argmax. Two calls with the same prompt
produce identical output — this is the regression-test contract.
The sampling temperature is chosen, not fitted. Classifier calibration fits
its own temperature on a held-out split (nnx.calibration, see
Concepts §18); the two are
unrelated.
4.2. Sampling with top-k¶
model.generate(prompt="...", temperature=0.8, top_k=40)
top_k=40 is the original GPT-2 default. Smaller top_k (≤ 10) gives more
focused, less creative output.
4.3. Nucleus (top-p) sampling¶
model.generate(prompt="...", temperature=1.0, top_p=0.9)
Top-p adaptively shrinks the candidate set per token. Combine top_k +
top_p to layer both filters (top-k applied first, then top-p over what
remains).
4.4. Repetition penalty¶
model.generate(prompt="...", repetition_penalty=1.2)
Divides positive logits of seen tokens by 1.2 (HF semantics — for negative logits, the penalty multiplies so the relative mass still drops). A penalty of 1.0 (the default) is a no-op.
4.5. Reproducible sampling¶
out1 = model.generate(prompt="x", temperature=1.0, top_k=20, seed=42)
out2 = model.generate(prompt="x", temperature=1.0, top_k=20, seed=42)
assert out1 == out2
The seed kwarg constructs a torch.Generator pinned to the model's
device — same seed + same prompt + same model = same output.
4.6. Stream generated token IDs¶
Pass on_token= to observe each newly generated token without changing the
returned text:
token_ids: list[int] = []
text = model.generate(
prompt="Once upon",
max_new_tokens=64,
on_token=token_ids.append,
)
The callback receives integer token IDs, not decoded text. Prompt tokens are excluded, and cached and full-recompute decoding invoke the callback at the same point. It fires immediately after a generated token is appended and before stop string detection, so it also observes the token that completes a stop string.
Exceptions raised by the callback propagate to the caller; model training mode
is still restored by generate()'s cleanup path. Decode accumulated IDs with
the configured tokenizer when a text stream is required.
5. Power-user decoding: LogitsChain¶
Some decoding setups need custom logit processors, such as a logit-bias
for forbidden tokens, a forced-decoder pattern, or a domain-specific
transformation. For these, build a LogitsChain and pass it
via generate(logits_chain=...). The chain bypasses the inline
construction generate() does from the temperature / top_k /
top_p / repetition_penalty kwargs.
from nnx import LogitsChain
chain = (
LogitsChain.builder()
.repetition_penalty(1.1)
.top_k(50)
.top_p(0.9)
.temperature(0.8)
.build()
)
text = model.generate(prompt="Once upon", max_new_tokens=64, logits_chain=chain)
The Builder enforces NNx's canonical order (RepetitionPenalty →
TopKFilter → TopPFilter → TemperatureScaling) regardless of call
order. Custom processors added via .custom(proc) append after the
canonical group in the order they were added.
When logits_chain=None (the default), generate() builds the chain
from the temperature, top_k, top_p and repetition_penalty kwargs.
5.1. Ordered pipelines: OrderedLogitsPipeline¶
LogitsChain (through its builder) sorts the standard processors into
the canonical order. Sometimes the order is the point: temperature before the
nucleus cut, a stage applied twice, or a custom stage between two built-ins.
Then use an OrderedLogitsPipeline. It runs its stages exactly
in declared order and is immutable data:
from nnx.generation import CustomStage, LogitsStage, OrderedLogitsPipeline
pipeline = OrderedLogitsPipeline.of(LogitsStage.temperature(0.5), LogitsStage.top_p(0.8))
stricter = pipeline.append(LogitsStage.top_k(5)) # a new pipeline; `pipeline` is unchanged
text = model.generate(prompt="Once upon", max_new_tokens=64, logits_pipeline=stricter)
pipeline.state() # {"version": 1, "stages": [{"kind": "temperature", "value": 0.5}, {"kind": "top_p", "value": 0.8}]}
Order changes the result: on logits [2, 1, 0], temperature 0.5 then
top-p 0.8 keeps one finite logit, top-p 0.8 then temperature 0.5 keeps two.
generate(temperature=..., top_k=...) |
LogitsChain |
OrderedLogitsPipeline |
apply_chain(processors=...) |
|
|---|---|---|---|---|
| Order | canonical (penalty → top-k → top-p → temperature) | canonical; custom processors after | exactly as declared, duplicates kept | the list's order |
| Holds | kwargs | processor instances (mutable list) | frozen stage specs + custom callables | processor instances |
| Serializable | — | no | yes: state() / from_state() (version 1) |
no |
- Precedence.
generate()useslogits_pipelineorlogits_chaininstead of the inline kwargs; passing both raisesValueErrorbefore the model is touched. The pipeline is compiled into fresh processors once per call, on the cached and uncached paths alike. Token history,on_token,stopandmax_new_tokensbehave as with the equivalent explicit processor list. - Validation. Every stage value is checked (finite, in its domain)
when the stage is made. A zero-temperature stage is greedy: it emits
±infargmax markers. It is therefore terminal. Appending after it, or prepending it before other stages, raises; nothing is reordered. - Immutability.
append/prependreturn new pipelines. A built-in processor passed in (its exact type) is copied into aLogitsStagespec by value, so mutating it later changes nothing. A nested pipeline is flattened into its stages. - Custom stages.
CustomStage(fn, tag=...)runs at its declared position. So does any callable, including a subclass of a built-in processor, which keeps its own__call__. Its state is the caller's (the pipeline keeps the reference), and it is opaque to the terminal rule. It serializes only through aLogitsStageCodecregistered for its tag withregister_logits_stage_codec. Data never names code to import, and a pipeline is never part of a checkpoint or a run identity.
examples/ordered_logits_pipeline.py
runs the order oracle, a round trip and a tiny local-tokenizer generation
offline.
6. How it composes with the rest of NNx¶
-
The causal-LM task (
nnx.lm_tasks.CausalLMTask) — the built-in next-token path. It declares these settings:- The alignment:
"shift_inputs"takes batches of token ids, shifted once;"pre_shifted"takes(inputs, targets)windows, never shifted again. - The vocabulary size (the logits' last axis), the ignore id (
-100) and an optional padding id. - Label smoothing for the objective, and its version.
Each batch may also carry an optional per-position loss mask (never an attention mask).
Bad shapes, dtypes, lengths and out-of-range non-ignored ids are rejected before any forward pass.
- The ignore id, the padding id and the loss mask select one set of valid positions. The objective's denominator, the NLL and the token accuracy all use that set.
task.objective()is an objective that sums the cross-entropy over valid positions, with the valid count as its denominator. Gradient accumulation therefore normalizes by the window's valid tokens, not by the batch count. An all-masked microbatch contributes nothing. An all-masked window takes no optimizer or scaler step, and its gradients are cleared.- Schedulers still advance once per epoch on the default epoch clock (or
once per committed update on the
optimizer_updateclock). A plateau scheduler skips an epoch whose validation record is unavailable. Such an epoch is never elected BEST in place of a training loss. task.eval_step()reports the epoch's unsmoothed NLL as the record'slossandmetrics["nll"]. This NLL is the total valid-token NLL over the valid-token count, independent of batching and right padding. It also reportsmetrics["perplexity"](exp(NLL),+infon overflow, never a clipped value) andmetrics["token_accuracy"]. An all-masked epoch is reported unavailable. No classification field is fabricated, andMonitorSpec("nll")(withMetricSpec("nll")declared) selects BEST on validation NLL.- The model never receives the attention mask. A mapping batch whose
attention_maskhas padding before a real token (left padding) is refused.pad_idalone does not detect left padding, and a batch without an attention mask cannot show it, so right-pad the batch. - The task configuration (and an optional declared tokenizer identity)
is checkpointed component state
"lm.causal_task". It is stored with the valid tokens the run's objective scored. The count starts from 0 for each fresh run, and a window the engine then skips is still counted. A resume with another configuration is refused before the first resumed update. - Batches for
"shift_inputs": token ids;(ids, loss_mask)with a boolean mask (an integer second part is refused: it could be targets); or a mapping{"input_ids", "labels"?, "loss_mask"?}. The mapping's HuggingFace-stylelabels(unshifted,-100where not scored) shift with the ids. - Batches for
"pre_shifted":(inputs, targets[, loss_mask])or{"inputs", "targets", "loss_mask"?}. - A mapping's
attention_maskis never a loss mask. Like a loss mask, it covers the ids ("shift_inputs") or the targets ("pre_shifted"). A padded position it marks that would still be scored is refused (declarepad_id, uselabels=-100or aloss_mask).extra_metricsdo not apply to the task and are refused. Half-precision logits are widened to float32 for the reported NLL. - A run with no monitor elects BEST from the validation NLL. An epoch
whose validation is unavailable never replaces an earlier BEST. An
unavailable first epoch, though, stands in until one has a value.
Declare
MonitorSpec("nll")withmetrics=[MetricSpec("nll")]to elect only epochs with a value.
A custom
train_step_fnstill works for anything else. The built-in KD / Mixup / CutMix / MoE steps score(rows, classes)logits and refuse a sequence model's output. - The alignment:
-
NNRuncontent-addressed persistence — TRANSFORMER runs hash the same way as any other run. Theomit-when-defaultinvariant onNNTransformerParamsis what keeps existing TRANSFORMERrun.idvalues stable as we add knobs over time. -
PEFT (LoRA) —
nnx.apply_lora_to(model.net, ...)works on theTransformerNN'snn.Linearprojections (the fusedw_qkv,w_o, and the SwiGLUw1/w2/w3). Test before publishing weights: pattern-match against every registration path of your config (named_modules(remove_duplicate=False)).With tied embeddings the output head's weight is the token embedding.
"*"also wrapslm_head, so it freezes the embedding too; target the attention / MLP projections by name to keep training it. ALinearregistered under two names is rejected before anything is wrapped.Each wrapper inherits the train/eval mode of the projection it wraps. Injecting a dropout-bearing adapter into an
eval()model therefore keepsgenerate()deterministic. Modes are runtime state and never serialized (see Concepts §11). -
Callbacks —
EarlyStopping,ModelCheckpoint,TensorBoardCallback,WandbCallbackall work unchanged. Loggingtrain_lossfor an LM is the standard signal. Perplexity isexp(NLL)of the unsmoothed valid-token NLL — the task'smetrics["perplexity"]— notexpof a smoothed or batch-averaged loss. -
Attention dropout and reduced precision — with
attn_dropout=0(or in eval mode), attention runs throughtorch.nn.functional.scaled_dot_product_attention. A nonzeroattn_dropoutin train mode takes the manual, seed-deterministic path.For FP16/BF16 layers, that path accumulates the scaled scores, additive mask, softmax and dropout in FP32; FP32 and FP64 keep their own dtype. It casts the attention result back to the value dtype before the output projection. A half or bf16 model therefore trains with dropout and returns logits in its own dtype.
The same rule covers the rectangular prefix/cache masks built by
PrefixTunerand cached decoding. CPU FP16/BF16/FP32/FP64 and CPU bf16 autocast are the lanes exercised by the test suite; other backend/dtype combinations are not claimed until run on that hardware. -
RMSNorm precision — every
RMSNormcomputes the mean of squares in at least FP32. This covers each block'snorm1/norm2and the finalnorm_out. FP16/BF16 inputs are promoted to FP32, and FP32 and FP64 keep their own dtype (the same rule as the manual attention path above).The norms of a
.double()model therefore keep FP64 range and precision: a1e30activation normalises to unit RMS instead of collapsing to zero. The normalised value is cast back to the input dtype before the weight multiply. The output dtype therefore still follows the input/weight promotion. Parameters,eps(the1e-6the GGUF writer exports) and checkpoint keys are unchanged.Double attention is covered too. For FP64 queries, the dropout-free SDPA path upcasts the FP32 additive causal mask (and
PrefixTuner's prefix/cache mask) to FP64. CPUscaled_dot_product_attentionsilently mis-applies an FP32 mask to FP64 queries once the sequence reaches the kernel's vector width (8 tokens on AVX2, 16 on AVX512). FP16/BF16/FP32 queries are unaffected and receive the mask unchanged.The RoPE cos/sin tables are computed in FP32 at construction and only cast by
.double(), so a double model's rotations carry FP32-level rounding.from_checkpointrebuilds the net in the default dtype. Call.double()and reload the FP64net_stateto restore a double model exactly. -
KV cache —
TransformerBlockexposes ause_cachekwarg whose off-path returnsNonefor the new kv tuple.GenerativeNNModel.generatedefaultsuse_cache=True. It runs a single prefill pass throughforward_with_cache, followed by incremental token-by-token decoding. A fixed 256-token CPU regression threshold requires at least 1.2× over full recomputation. Results on other workloads and hardware vary.Once generation slides past the window, the cache is rebuilt each step for RoPE-position correctness and the cost converges to the full-recompute path.
7. Scope explicit¶
The decoder-only LM path covers:
- Decoder-only architecture (LLaMA / Mistral conventions: RMSNorm, RoPE, SwiGLU, tied embeddings).
- HF tokenizer integration via the
tokenizersRust library. - Autoregressive
generate()with greedy + sampling (LogitsProcessorchain: temperature / top-k / top-p / repetition-penalty). - KV-cache acceleration on by default, with a fixed 256-token CPU regression test requiring at least a 1.2× speedup over full recomputation.
- CPU-friendly TinyStories-class training (sub-30-min runs).
- Integrations built on the LM path:
PrefixTuner/PromptTunerPEFT for frozenTransformerNN(see Concepts §11).dpo_train_step_factorypreference fine-tuning (seedocs/dpo.md).- Experimental NNx-tagged GGUF export for inspection or patched runtimes (see
docs/gguf.md).
Out of scope:
- Multi-GPU / multi-node training.
- FlashAttention v3 (uses
torch.nn.functional.scaled_dot_product_attention, which picks the best backend kernel automatically). - Tensor parallelism / FSDP / ZeRO sharding.
- Production-scale RLHF / PPO (DPO is the lightweight preference path NNx ships; full PPO is intentionally out of scope).