tenstorrent · tt-tnt

A small language model trained on Tenstorrent hardware.

tt-tnt is a Llama-3-style language model trained from random initialization on Tenstorrent Blackhole hardware with ttml (tt-train), converted, packaged with tt-model, and served through the Tenstorrent vLLM plugin.

It is a small model: 122,962,944 parameters, a 512-token context window, one epoch over the 352,641,058-token training split of a 391.8M-token corpus. No instruction tuning — it continues text rather than answering questions, and it repeats itself under greedy decoding. The measurements below describe what it does and where its limits are.

The result worth knowing: its vocabulary is laid onto the 110 usable cores of a harvested 11×10 Tensix grid, and that placement is not decoration. Source-characteristic tokens occupy distinct regions of the die, and restricting sampling to a region raises that region's register — replicated across four generation seeds at p < 0.004 each. Direction on this die means corpus register, measurably, on a 123M model.

Sparse routing wins; the geography is nearly free. From scratch, one epoch each, MoE beats dense on validation — 2.8098 against 2.8748, |t| = 7.3, 20 of 22 paired signs, and the gap widens across training. Read it as the ordinary MoE bargain: 3.62× total parameters at 0.989× active compute. Replicated at a second seed — +0.0354 against +0.0481, pooled +0.0417 over 44 points, and both runs separate late rather than early. Separately, and this is the part that is ours: routing by physical die address costs 0.0118 nats. The four-arm comparison ran on 2026-08-20 — dense, learned gate, seeded gate, frozen gate, one warm-start checkpoint, one seed, one corpus. Freezing the gate to die geography, never letting it learn, lands within 0.0118 nats of a freely-learned gate (|t| = 5.1, 14/15 signs): about 15% of the run's own step-to-step floor. Seeding the gate buys nothing measurable at all. Nearly free is not free — but on this hardware, geography is close to costless.

And it can be taught to think out loud — 98% of the time. A five-slot think-block (offer / accept / add / stakes / handback) before each story continuation, one slot per improv failure mode. The model emits well-formed blocks in 784 of 800 generations where the no-think control emits none, and substituting another story's block changes 100% of continuations, so it steers rather than decorates. It does not yet move any of the four failure-mode scores. The generations explain that better than the scores do: it planned add: dance and then wrote a scary dog; another block set stakes: up and the scene resolved into contentment. The syntax of intention is perfect and the intention is not honoured — the block is context the model conditions on, not an instruction it obeys. On the same opening the control arm writes plainly better prose. Next unit is a skit: two or more turns with a partner who answers, because a single continuation gives handback nothing to hand back to.

What it does best

Routing by physical die address. Tokens can be assigned to experts by where they live on the harvested 11×10 Tensix grid rather than by a learned gate, and it costs 0.0118 nats against a gate free to learn (|t| 5.1, 14/15 signs). Source-characteristic tokens occupy measurably distinct die regions — cell purity 0.546 against a 0.231 permutation floor. This is the part no other model can do, because it needs a real harvested grid to be about.

A complete, cheap exercise of the whole stack. One epoch in ~65 minutes on one p300c: training through ttml, sampling per-core on the device, serving through the vLLM plugin, packaging as a tt-model bundle, exporting to a host-portable HF directory. Because the loop is cheap it gets run, and running it has found seven defects in the surrounding stack — each now with a regression check.

Sparse routing that measurably pays. A Mixture of Enthusiasts beats the dense baseline from scratch, replicated at two seeds — pooled +0.0417 nats, |t| 8.0, 39/44 signs — separating late in training in both runs.

Feature support

CapabilityStateEvidence
Training on Blackhole (ttml/tt-train)shippedone epoch, ~65 min, one p300c
Multi-chip DDP[1,2] [1,4]the 4-chip mesh froze the host; note drafted in-repo
Sparse MoE — Mixture of Enthusiastsshippedreplicated win, two seeds
Die-region expert routingshipped0.0118 nats to freeze routing to physical address
Thinking — five-slot think-blocksformat effect98% adherence; steers but does not yet govern
Per-core Gumbel sampling on deviceshippedcustom kernels, per-core RNG streams
vLLM serving via the TT pluginshippedtt-model serve, OpenAI-compatible
tt-model bundle packagingv4 v5v5 self-contained needs wheels assembled
Host-portable HF exportshippedscripts/chat.py runs it CPU-only
Tool calling / reasoning parsersn/aplumbing exists; a base model declares none
Chat template / instruction tuningn/abase completion model by design
Skits — multi-turn improvnextdesign note in the spec

01 — the model, asked

Sample output.

Every notable change to this project ends by putting the same prompt to the current model and writing down what came back. The prompt is deliberately a request: this is a base completion model with no instruction tuning, so it cannot answer, only continue. What comes back is a reading of its register and its grip on a sentence, not of its knowledge.

Tell me a way to go faster than light that will not work.

greedy

I will go faster than lightning, and I will go faster than lightning.

temperature 0.8

Go out! Is it your machine? Yes, yes! Yes, yes! Yes, yes! Yes, yes! Y

temperature 1.0

Tell me, can you? Ask him for wisdom. Tell him not. Tell him to sail

It hears "faster than light" and reaches for lightning — the nearest thing in a corpus that contains a great deal of weather and very little physics. Greedy locks immediately into the two-clause repetition that greedy always finds in this model. At 0.8 it collapses into pure affirmation. At 1.0 it produces the most interesting line of the three, which is oracular in shape and empty in content, and that is roughly where this model lives.

Nothing in the output notices that the request was a request. Nothing declines the premise, because nothing in 392M tokens of Gutenberg and TinyStories has ever declined anything.

Source: episod-log.md, 2026-08-16, model artifacts/hf-tt-tnt-1024a. These are ad-hoc samples from scripts/evaluate.py --try, which writes to a scratch directory outside docs/ and outside git precisely so nobody mistakes them for a measurement. The comparable numbers come from two digest-pinned frozen prompt sets, described below.

02 — the network

122,962,944 parameters, eight blocks.

A Llama-3-style decoder, drawn from its own config.json. Two of the annotations are engineering decisions rather than descriptions: the attention mask is passed as null so the SDPA kernel selects its Causal mode instead of Arbitrary — worth 1.41× — and the FFN width of 2816 is 88 tiles, which is 8 × 11, chosen to tile a grid that is 11 cores wide.

tt-tnt-1024 model architecturett-tnt-1024122,962,944 parameters · 8 blocks · d_model 1024 · 512 context · bfloat16tokens [B, T]T ≤ 512embed_tokens32,000 × 1024 · tied with the LM head (tie_word_embeddings: true)× 8RMSNormeps 1e-05 · gamma learns only with stochastic rounding onQ / K / V projection16 query heads × 64 · 4 KV heads × 64 · GQA 4:1RoPEtheta 500,000 · applied to Q and Kscaled dot-product attentioncausal · mask passed as null so the kernel picks Causal, not Arbitrary (1.41×)+ residualRMSNormeps 1e-05SwiGLU MLPgate 1024→2816 · up 1024→2816 · SiLU · down 2816→1024+ residual2816 = 88 tiles = 8 × 11, which tiles the harvested 11-wide gridfinal RMSNormlm_head1024 → 32,000 · weights tied to embed_tokenssamplingtop-k / greedy on the standard path — or the topological sampler
Weights are tied: lm_head is embed_tokens transposed. RMSNorm gammas learn only with stochastic rounding enabled — in bfloat16 an Adam update of ~3e-4 against a ulp of 0.0039 rounds back to 1.0 every time without it.

Which part of the stack runs which part of the model

Bottom to top. The amber band is everything this repository adds; the dashed band is tt-inference-server, which is installed on the development machine but is not in this project's path — serving goes through vLLM and the Tenstorrent plugin directly, and no manifest here references it.

tt-tnt serving and training stackthe stack, bottom to topwho owns which part, and where our own code sits in ittt-inference-serverPRESENT ON THIS BOX, NOT IN OUR PATH — we serve through vLLM directly· deployment / model catalogue· not referenced by any manifest herevLLM coreOpenAI-compatible server and engine· api_server· engine core· scheduler front halfvllm-tt-plugin @c127c17the Tenstorrent backend for vLLM — where the decode defect lived· platform.py· model_runner.py (sample_tokens)· scheduler.py· device state slotsOUR CODEeverything this project adds, training and serving· bundle/tt_tnt_adapter.py — find_grid shim · weight-cache fingerprint · plugin-freshness guard· kernels/*.cpp — gumbel · argmax-with-index · per-core reduce · tile_writer· artifacts/token_core_map.npz — 32k tokens placed on the 11×10 die· train/model.py · train/checkpoint.py — null-mask path · DDP topology re-marktt_transformers (inference) ttml / tt-train (training)the two model implementations — different code, same weights· Attention · MLP · LMHead · KV cache · paged attention · Generator· Llama model · AdamW · 4-chip DDP · stochastic roundingTTNNthe op layer we reach for directly when writing kernels· matmul · SDPA · reduce · topk · rand_tile· generic_op · KernelDescriptor · CBs · TensorAccessortt-metalkernels, dispatch, and the JIT that builds them· LLK · circular buffers · NoC transactions · JIT kernel buildBlackhole p300cthe silicon everything above is shaped around· 11 × 10 = 110 usable Tensix (harvested from 17 × 12)· per-core L1 · NoC torus · SFPU (incl. the PRNG) · DRAM
Two model implementations sit at the same level: ttml trains the weights, tt_transformers serves them. They are different code over the same tensors, which is why a parity gate between them exists at all.
model component to stack ownerwhich stack layer implements which partthe same model, twice: ttml trains it, tt_transformers serves it, and the yellow rows are oursMODEL COMPONENTTRAININGINFERENCESILICONembed_tokensttml Llamatt_transformers EmbeddingDRAM → L1RMSNormttml (gamma needs SR)tt_transformers DistributedNormSFPUQ/K/V projectionttml Llamatt_transformers Attentionmatmul, 8×8 gridRoPEttml (theta 5e5)tt_transformers ropeSFPUattention (causal SDPA)ttml — null mask ⇒ Causaltt_transformers SDPAFPU + SFPUKV cachen/a while trainingtt_transformers / paged by pluginL1 + DRAMSwiGLU MLPttml Llamatt_transformers MLPmatmul, FFN 88 tileslm_head (tied)ttml Llamatt_transformers LMHead8×8 = 64 coressampling — standardn/aplugin: topk(k≤32) + all_gatherSFPUsampling — topologicaln/aOURS: gumbel + argmax kernelsall 110 coresoptimizer / DDPttml AdamW + 4-chip DDPn/aNoC all-reducecheckpoint I/OOURS: topology re-markn/ahostgrid selectionn/aOURS: find_grid shimharvested 11×10weight cachen/aOURS: source-rev fingerprinthost diskRows shaded amber are implemented in this repository. Everything else we configure, patch, or call.
Every component against its training owner, its inference owner, and where it lands on silicon. Amber rows are implemented here, and four of them exist because the stock path did not fit a harvested die.

03 — end to end

Trained, converted, packaged and served on Tenstorrent tooling.

The point of this project is not the model. It is that every stage between a random initialization and an HTTP endpoint runs on Tenstorrent software, with nothing quietly substituted from elsewhere.

  1. Corpus and tokenizer

    A nine-source, licence-audited blend built to a 400M-token budget, and a 32,000-token byte-level BPE tokenizer trained on that blend rather than inherited from someone else's vocabulary. Both are CPU work.

  2. Trained from random init on Blackhole, with ttml

    tt-train's own trainer, its optimizer, its checkpointing, and its Llama implementation. The architecture is vendored as a verbatim copy of tt-train's nanollama3.yaml so it cannot change under a tt-metal upgrade without a test failing.

  3. Converted to Hugging Face format, and checked numerically

    The conversion is verified against an independently derived pure-NumPy reimplementation of ttml's forward pass, written from the C++ source rather than from the converter, so the two paths reach logits by different routes. They agree to a maximum absolute logit difference of about 6e-6. This mattered: an earlier conversion loaded cleanly, tied its weights correctly and generated fluent prose while computing the wrong function, because of a RoPE row-layout mismatch worth 1.3 nats. Only the numerical comparison caught it.

  4. Packaged with tt-model

    A manifest declaring the runtime, the mesh, the entry point and the ttnn version floor, plus a serving adapter that travels with the bundle.

  5. Served through the Tenstorrent vLLM plugin

    tt-model serve episod/tt-tnt. The plugin logged the bundle's adapter registering out of the extra-models directory, the mesh opening at (1, 1), and /v1/models reporting the manifest's declared context on the wire. The procedure — and the traps that make it more than one command — is written up in docs/serving-with-tt-kernel.md.

Which checkpoint went all the way

The checkpoint that has been packaged, published to the Hub and served end to end is tt-tnt-v3 — a 22M-parameter model at a 2048-token context, published as episod/tt-tnt. The model designated current, tt-tnt-1024a at 123.0M parameters and a 512-token context, is the lowest-loss checkpoint this project has when everything is measured at a matched window; it was trained later, and the multi-chip serving manifest that would carry it is written but its weights are not published. Those are two different claims and the page keeps them apart.

Parameters (1024a)
122,962,944
Layers / heads / KV
8 / 16 / 4
Hidden · SwiGLU
1024 · 2816
Context
512
Vocabulary
32,000
Steps
10,764
Train tokens
352,729,403
Epochs
1

Parameter count read from artifacts/hf-tt-tnt-1024a/model.safetensors; shapes from its config.json; steps and token count from the run's own train.log. Llama-3 style throughout: RoPE (θ = 500000), RMSNorm, SwiGLU, grouped-query attention, tied embeddings, bfloat16 weights. AdamW at a constant 3e-4, weight decay 0.01, stochastic rounding on, seed 5489.

04 — the corpus

It ships as a recipe, because it legally cannot ship as text.

The blend is nine sources mixed to a 400,000,000-token budget. It came in at 399,508,203 tokens — 491,797 short, −0.123% — counted with the trained tokenizer rather than estimated, with every slice within 0.083 points of its target share. blend.txt carries the SHA-256 24f3d112…f4d1.

The blend as built. Figures copied from the manifest the blend itself writes, and held to it by a test.
SourceEmitted tokensShareLicence
tinystories124,030,36431.046%CDLA-Sharing-1.0 share-alike
gutenberg_children59,984,10415.014%MIT packaging; public-domain texts
wikipedia_simple59,597,64914.918%CC-BY-SA-3.0 share-alike
spine53,915,06513.495%MIT packaging; public-domain texts
procedural47,994,27212.013%MIT packaging; public-domain texts
folklore32,078,4648.029%MIT packaging; public-domain texts
weird15,977,9603.999%MIT packaging; public-domain texts
poetry3,950,5360.989%CC0-1.0
flavour1,979,7890.496%MIT packaging; public-domain texts

Those two share-alike rows are 45.96% of the blend, under two copyleft terms that cannot both be satisfied by one concatenated file. So the corpus is not redistributed anywhere. What is published, as episod/tt-tnt-corpus, is everything needed to rebuild it byte-identically and nothing that would redistribute it: the source registry with pinned dataset revisions, the fetch, prepare, measure and blend scripts, the generated licensing table, and the provenance manifest carrying the blend's SHA-256.

A published copy is a copy, and copies drift. scripts/publish_corpus_recipe.py defaults to --verify: it downloads what is currently on the Hub, diffs it byte for byte against what the working tree would publish right now, prints which files differ and exits non-zero. That mode exists because the manual copy had already drifted twice, once carrying a hand-transcribed figure that claimed a 0.46% gap where the real one was about 1.71%.

Whether weights trained on share-alike data are a "Data Derivative" under CDLA-Sharing-1.0 or an "Adaptation" under CC-BY-SA-3.0 is not settled, and this project does not assert that they are not. Anyone publishing weights trained with this code should reach their own conclusion rather than inheriting one.

05 — vocabulary on the die

A default path distributes the vocabulary too. It distributes it by token ID.

Read from models/tt_transformers/, the generic path a Qwen3-8B runs through today. The comparison is not sharded versus not — it already shards. The difference is the assignment function, and what survives to be sampled.

vocabulary placement on the diewhere the vocabulary lives on the diesame 11 × 10 harvested grid, two ways of assigning 32,000 tokens to itdefault path — 64 of 110 coreslm_head runs on an 8×8 grid; bands are contiguous token-id rangestt-tnt — all 110 corescolour varies smoothly: neighbouring cores hold related tokensAssignment is arithmetic: start = device × (padded_vocab / num_devices).Two adjacent cores hold IDs adjacent by number, which says nothing about meaning.Assignment is measured: balanced k-means, then a QAP anneal onto the grid.Adjacency is semantic, which is what lets a neighbourhood mask mean something.
Left: size_per_device = padded_vocab_size // num_devices, then start = device × size_per_device. Right: balanced spherical k-means onto 110 cells, then a QAP anneal so semantically close tokens land few NoC hops apart.
stageQwen3-8B via tt_transformerstt-tnt
vocabulary placement contiguous token-ID ranges, subdivided into column chunks sized to avoid L1 OOM assigned from a measured embedding layout, ~291 tokens per core, one tile each
cores doing the head8 × 8 = 64 (a dedicated lm_head_core_grid) 11 × 10 = 110, every usable core
what survives to sampling ttnn.topk(k ≤ 32) per device, then all_gather — anything outside its shard's top 32 cannot be selected every token stays live; Gumbel noise on all of them, winner by max over the full field
randomnessseeds uploaded as a tensor each core's own Tensix PRNG (rand_tile)
region-aware sampling not expressible — after the gather, shard membership carries no meaning the point of the design: a neighbourhood mask restricts the draw to cores within N hops
cost per token≤ 32 values per device gathered two values per core (880 B) since the argmax-with-index kernel

The honest reading

Qwen3-8B's arrangement is the correct engineering for its job: top-k of 32 per device is cheap and bounded, and an ID-order split is free and needs no measurement. It scales to 8B across multiple chips, which this 123M model on one chip does not attempt — these are not the same weight class and no throughput comparison is being drawn.

What tt-tnt adds is not speed. It is that region membership survives to the draw, so the sampler can be asked something the top-k path cannot express: give me a continuation from next door. One generation touches 6–9 distinct cores of 110, not all of them — Gumbel-max lands wherever the winning token lives, and common tokens cluster. The 110 cores are the machinery, not the itinerary of any single sentence.

So we asked it, and it answers

"Give me a continuation from next door" was a claim about what the sampler could express. On 2026-08-19 it became a measurement, and it needed two things established first — because the layout was justified by tokens separating in embedding space, and embedding space is 1024-dimensional while the die is 110 usable cells. A projection can destroy exactly the structure that motivated it.

The regions survive the projection. Source-characteristic tokens land in distinct places on the grid: cell purity 0.546 against a label-permutation floor of 0.231, concentration 13% tighter than chance, centroids 36% further apart. The effect strengthens when the 500 most frequent tokens are excluded — the control that collapses a frequency artefact.

And the regions steer. Restrict sampling to the cells within two NoC hops of a source's centroid, and that source's own register rises — measured with the same per-source language models the evaluation suite already uses, against a floor built from 20,000 derangements of the region labels.

Four independent generation seeds, every one above the floor’s 99th percentile. Direction on this die means corpus register.
generation seedown-register liftzp (one-sided)
0+0.0928+3.200.00365
1+0.1157+4.020.00070
2+0.1164+4.400.00020
3+0.1049+3.740.00075

It is not uniform, and the uneven part is stated rather than averaged away: poetry, tinystories, wikipedia_simple and procedural carry the effect; dialogue, spine and flavour go the wrong way. Seven of ten sources prefer their own region. The floor controls for which region is called what — the four seeds are what control for the sampling itself.

A Mixture of Enthusiasts

Mixture of Enthusiasts — provenanceTraining MoE comes from ttml (tt-train); inference MoE is a separate ttnn op. LlamaBlock.mlp is the seam that makes Llama plus MoE a subclass. Routing comes from the token-to-die map.a Mixture of Enthusiasts, and where each piece comes fromenthusiasts, not experts — 123M parameters at one epoch buys enthusiasm about a corpus source1 — TRAINING AND INFERENCE MoE ARE DIFFERENT CODEBASESthe easiest mistake here; they share a name and nothing elseTRAININGttml (tt-train)models/deepseek/moe_sparse_ep.pyclass SparseMoEEP(MoE)trainable · autograd · 13 tests pass on 2 chipsarrived in tt-metal v0.77.0 (#43508)INFERENCEttnn.experimental.moe_computeoperations/experimental/ccl/moe_compute/moe_core_placement.cpp — tilize/matmul/combine coresfused · 35 tests pass single-card Blackhole1×1 compute_only path — no CCL, no fabricno shared code2 — THE SEAM: Llama + MoE IS A SUBCLASS, NOT AN ARCHITECTURE CHANGEtt-tnt keeps GQA, RoPE, its tokenizer and its die map; only the FFN slot changesttml/models/llama/transformer.py:177class LlamaBlock:self.mlp = LlamaMLP(...)LlamaMLP.forward(Tensor) -> Tensorours — twenty lines, and it TRAINSclass EnthusiastBlock(LlamaBlock):self.mlp = SparseMoEEP(...)SparseMoEEP.forward(Tensor) -> Tensorsameidentical signature is the whole argument — 10.5625 to 7.7500 in 20 steps on one Blackhole card3 — THE ROUTING IS OURS: A TOKEN GOES WHERE IT LIVESnot a learned gate — an address on the harvested 11×10 gridtoken ide.g. 4,812celltoken_core_map.npzregionVoronoi by measured centroidenthusiastthe expert that owns itmeasured, not asserted: source-characteristic tokens occupy distinct die regions —cell purity 0.546 against a 0.231 permutation floor, and steering to a region raises thatregion's register across four generation seeds (p < 0.004 each).and it is nearly free: a gate FROZEN to this geography costs 0.0118 nats against a gatefree to learn (|t| 5.1, 14/15 signs) — ~15% of the run's own step-to-step floor. Seedingalone buys nothing measurable (+0.0044, signs 8+/7−).SPARSITY ITSELF DOES pay, from scratch: 2.8098 vs dense 2.8748 at one epoch (|t| 7.3,20/22 signs), and the gap widens. But that is 3.62x TOTAL params at 0.989x ACTIVE compute —the ordinary MoE bargain, not a claim about geography.4 — THE GAP THIS BOX SITS INupstream ships MoE configs for single-card and for 6U Galaxy (32 chips). Nothing for four.tt-tnt already vendors a [1, 4] mesh-graph descriptor because ttml ships defaults for 8 and 32 only— a mismatch there does not error, it HANGS in the first gradient all-reduce (train/run.py).It exists now, at [1, 2]: four arms trained on this box on 2026-08-20. The [1, 4] mesh isthe part still open — it hard-froze the host ~20s after MoE opened it, with no OOM and no panic.
Where each piece comes from. The training MoE and the inference MoE share a name and no code; the seam that makes Llama + MoE a subclass is a slot with a matching signature; and the routing is the part that is ours.

Enthusiasts rather than experts. 123M parameters at one epoch buys enthusiasm about a corpus source; the naming should not overstate the artifact.

ttnn.experimental.moe_compute — upstream's fused MoE op — runs on a single Blackhole card. That corrects a note this project had recorded claiming MoE needed a 32-node mesh: that was tt-train's expert parallelism, not this op, which has a 1×1 compute-only path. So the routing was replaced. Not a learned gate: a token goes to the enthusiast that owns its cell on the grid — token id, to cell, to region, to expert.

It computes correctly, validated by upstream's own goldens. The seam matters: the patch sits at the routing generator, before the goldens are built, so they compute the expected answer for this routing rather than for one that would otherwise have had to be trusted. Patched any later, a passing PCC would have meant nothing.

And it trains. The same day, six of eight feed-forwards were replaced with SparseMoEEP — 10 routed experts plus one shared, top-2, 928 wide — and the model stepped: 10.5625 to 7.7500 over twenty steps at batch 8, real validation 7.5344, on one Blackhole card. The same descent shape the dense model shows over its own first twenty. It is twenty lines because LlamaBlock.mlp is a plain attribute and the signatures match; tt-tnt keeps GQA, RoPE, its tokenizer, its vocabulary and its die map.

Two faults were caught by arithmetic before any hardware paid for them. The obvious expert width gives 0.38× the dense model's active parameters — that arm loses on capacity while appearing to report on routing; 928 gives 0.989×. And plain Voronoi routing is 7.66× imbalanced, one enthusiast owning 20.9% of the vocabulary and another 2.7%, which a frozen gate could not survive; a mass-balanced partition reaches 1.50× while staying spatial — and is a different routing from the one whose register effect was measured.

What it does not show is that the routing is good. Load is uneven by construction — the Voronoi partition of the die gives flavour 23 cells and spine 3 — and no claim is made about throughput or locality benefit. That is the interesting question and it is not answered here.

06 — the bundle

The bundle carries the tt-metal runtime patches it needs.

bundle/tt_tnt_adapter.py ships inside the distributed model bundle, applies at import time in the serving process, and is inert everywhere else. It is the demonstration that a model can carry the runtime change it needs without that change having to land upstream first.

find_grid, on a harvested grid

Upstream's helper never asks the device how large its compute grid actually is. A harvested Blackhole has fewer usable columns than the architectural maximum — the p300c this was developed on reports 11×10, not 12×10 — so for a 384-wide hidden dimension the helper returns a 12-column program config and RMSNorm fails at the first decoder layer. Given the real width, the same unmodified search finds a 2×6 grid that fits. The dimension was fine; the assumption was not.

Converted-weight cache keyed without a source revision

The reuse decision for a converted-weight cache is a bare existence check — no revision, no content hash, no comparison against the source weights. A project that iterates checkpoints under a stable repo id therefore gets the old model's weights under the new model's config, logged as an ordinary warm start. That happened here: a retrained model was republished, the server came up clean, reported the right context length, and ran the previous model. The adapter scopes the cache path by a fingerprint of the source weights. In the file's own words: it does not fail, it lies — and that is the failure mode the patch removes.

Precision, defaulted to accuracy

The bundle defaults the serving optimization to accuracy rather than performance. The plugin's log confirmed it took effect on device.

Both patches describe themselves in-file as shims, not fixes, each with the upstream change that would make it a deletable no-op. docs/serving-with-tt-kernel.md is the how-to for the whole path: what the manifest fields do, the command that brings a server up, how to confirm you are serving the weights you think you are, and the traps between — a launch command that depends on the working directory, an environment variable that must be left unset, a newer ttnn wheel that cannot compile a kernel on this host, and two independent caches that go stale behind a name that did not change. What genuinely cannot be fixed from this side is written up in docs/upstream-tt-metal-asks.md, each ask with a reproduction and an explicit note on whether it blocks anything here. None of them currently does: the workarounds cost nothing measurable, and one is filed purely because the failure mode is expensive to debug and the next person to hit it deserves the analysis. One entry has been corrected in place after it turned out the thing it said could only be fixed upstream could in fact be fixed here — that story is in §05.

Do not read "served successfully" as "generates correctly"

The adapter opens with a section titled known unfixed defect — read before trusting output. Generation on the served path is wrong in a way CPU generation is not, and neither patch addresses it. The same weights, the same context, a different answer depending on the path taken — so the error is in the decode / KV-cache path rather than in the weights, the conversion, or the patches. The tt_transformers PCC gate passed at 0.9940–0.9998 while the defect was present, because it exercises prefill far harder than long decode. A green PCC is not evidence of correct generation.

07 — training throughput

4.62× faster training, from two independent changes.

1.41× — redundant explicit attention mask

ttml's trainer always passed an explicit attention mask, and the fused SDPA kernel picks its mask mode from whether a mask object was passed rather than from what is in it. So every step paid for the arbitrary-mask path: roughly double the attention FLOPs, with load balancing disabled. tt-metal could not be edited. But ttml ships two Llama implementations, and while the C++ one binds the mask as non-optional, the pure-Python one reaches an SDPA binding that already declares its mask optional. No rebuild, no monkeypatch, no tt-metal edit.

Checked before believing it: with the mask still passed, the two implementations cost 521.7 vs 521.9 s/1000 steps at the 384 shape — within 0.4%. The entire difference is the mask, not the language. Causality was verified directly: perturbing token t leaves every earlier logit bit-identical on both paths.

3.98× — four chips instead of one

Every run before this used one chip of four on a TT-QuietBox 2. The blocker was that a DDP-only run must open a [1,4] mesh while the stock descriptor declares the physical [2,2] wiring — and that mismatch is not rejected. The mesh opens, the model trains at full speed, step 1 completes, and step 2 hangs forever in the first gradient all-reduce. The fix needed no tt-metal change: a vendored descriptor declaring the logical shape.

Seconds per 1000 steps. 1024 shape, batch 64, seed 5489, 300 steps, measured through the training entrypoint itself.
Configurations / 1000 stepsvs. baseline
C++ model, one chip — the starting point892.91.00×
Python model (null mask), one chip770.21.16×
C++ model, four chips223.24.00×
Python model (null mask), four chips193.44.62×

At the smaller 384 shape the mask fix is worth more on its own: 503.3 → 356.7 s/1000, 1.41×. The two levers are independent, and they compose.

The negative control

synchronize_gradients silently returns early when the parallelism context is uninitialised — so a run can be four times faster and quietly wrong, and it draws a perfectly ordinary loss curve while it does. Proven synchronised two ways: with stochastic rounding off, after DDP steps all four replicas are bit-identical, maximum difference 0.0 over 66 tensors; and a four-chip run's validation loss tracks the single-chip run at the same seed to within 0.048 against a 0.194-nat noise floor. Then, deliberately, the broken version: skip the context init and the replicas drift by 2.44e-3. That is what makes the 0.0 a measurement rather than an assumption — the instrument was shown to be capable of detecting the exact silent failure it was claiming did not occur.

The qualifier on that first leg is not decoration, and finding it corrected an earlier write-up here. stochastic_rounding is on in this project's real training config — it has to be, because bf16 parameters at 1.0 have a ulp of 0.0039 and every update otherwise rounds straight back — and it breaks replica identity, because each device draws its own rounding decisions and the four replicas then perform independent random walks about a common trajectory. Measured over four DDP steps: with it off, 0 of 66 parameters' replicas differ; with it on, 66 of 66 do. They remain four coherent models rather than four broken ones — every one saw every all-reduced gradient — but a --ddp N checkpoint holds replica 0's weights, not "the" weights. That is a real choice, and it is why the checkpoint guard below is structural rather than a numeric tolerance: a "verify the replicas agree, then save" check would pass under a config nobody uses and refuse under the one this project recommends.

Four-chip checkpoint saving

The training step re-marks each replicated parameter's topology as sharded while the data stays genuinely replicated, so the saver wrote all four replicas concatenated — 1,475,602,288 bytes against 737,824,624 for the same single-chip run. The gather was not the bug; it faithfully honoured wrong metadata. For a while the saver simply refused to write such a file rather than produce a plausible-looking corrupt one, and the upstream ask recorded this as something that could not be fixed here.

That expectation did not hold. The topology marking turns out to be writable from Python, so it can be corrected by any holder of the tensor: the saver now re-marks each parameter as replicated immediately before a write and restores the original marking immediately after. No data moves, and a four-chip save costs what a one-chip save costs. The file is back to 737,824,624 bytes, and after 50 data-parallel steps every tensor in it is bitwise equal — maximum absolute difference 0.0 — to replica 0 read independently through a path that does not consult the placements being corrected. The numerical parity gate has now run against a data-parallel checkpoint for the first time, at 2.56e-6.

The upstream defect is still real and still worth fixing at source: every consumer of a data-parallel parameter's topology is currently told something false and has to know to disbelieve it. What changed is only that this project is no longer waiting on it.

Working with tt-model-manager

tt-model-manager — the tt-model CLI, previously tt-kernel-package-manager — is how this model is packaged, published and served. The relationship is deliberately adversarial in one direction: tt-tnt is a hard consumer, and reports what breaks.

tt-model doctor episod/tt-tnt-1024     # is the surrounding toolchain adequate?
tt-model pull  episod/tt-tnt-1024     # kernels, runner, weights
tt-model serve episod/tt-tnt-1024     # through the TT vLLM plugin

Being a real consumer has found defects the manager's own tests could not: a version resolver trusting frozen editable metadata over the source tree, a toolchain probe describing the wrong interpreter, an instance report that sat after a return and never ran, a bundle pinning a temp-directory TT_METAL_HOME, and a --restore-card that would have published the wrong model's card. Two are merged upstream, two are in review, and the repeatable subset is automated — scripts/stack_probe.py re-checks every one of them in a single command, so the next regression is caught rather than rediscovered.

08 — the instruments

How the model is measured.

A 123M-parameter model trained for one epoch is not interesting. Knowing exactly what it does and does not do, with the error bars attached and the false positives thrown out, is.

Two frozen prompt sets

15 prompts and 45 prompts, both digest-pinned, and never pooled: one set is chosen and both sides of a comparison are scored on it. A prompt that proves diagnostic gets promoted into a new set with new ids in a deliberate commit — never by editing an existing set, whose digest every committed measurement depends on.

A behavioural metric

Genre collapse, story-frame collapse, lexical-habit collapse, termination rate, 4-gram repeat rate, longest repeated span, register margin, nearest-source, prompt engagement, generated length. Paired by prompt, reported with a 95% interval and the minimum difference the comparison had the power to detect — so a null result is bounded rather than merely stated.

A per-source loss breakdown

One validation loss over a nine-source blend is an average over an enormous spread. Measured on the first blend-trained checkpoint: TinyStories 1.82 nats, poetry 5.01, Simple English Wikipedia 4.20. Held-out where genuinely unseen text exists, and marked n/a where it does not, rather than substituting a number that looks like one.

A position-wise context probe

Does the model use the context it has? On the 2048-context checkpoint, loss by position bucket: 4.21 at [0,32), 3.26 at [32,64), 2.91 at [64,128), then 2.88, 2.83, 2.82, 2.81 out to [1024,2048). Read honestly, nearly all of the benefit arrives in the first ~128 tokens; past ~256 the improvement is about 0.02 nats a bucket against a standard error near 0.08. The long window is no longer actively useless. That is a weaker claim than "it uses its context", and it is the one the numbers support.

A seed-noise floor

Two runs, identical in every respect except the random seed. Everything they differ by is noise, by construction, and that difference is the floor under which no claim in this project is allowed to stand. Measured over 22 matched checkpoints, the seed alone moves validation loss with a standard deviation of 0.1944 nats, and it does not even hold a consistent sign: 8 of 22 checkpoints negative, 14 positive, two-sided p = 0.286.

Every reported difference is printed beside its ratio to that floor. Anything within about 1.2× of it is labelled NOT INTERPRETABLE regardless of what its confidence interval says — the interval does not get a vote, because the floor already demonstrated that intervals can be cleared by nothing at all. The threshold is not a round number chosen for comfort; it is set just above the largest false positive this project has actually observed.

There are two ways to fool yourself here, and both are gated. A delta can clear its interval while sitting inside the noise floor. Or it can sit far outside the floor while failing to clear its own minimum detectable difference — a large ratio over a tiny denominator — which is labelled below paired detection instead. Both gates have to pass before a difference gets called better or worse.

The four labels above are this page's entire colour system. They are the verdicts scripts/evaluate.py can stamp on a measured difference. The next section borrows the same four for the external benchmarks' chance gate — the same shape of rule against a different null — and says so where it does it. Nothing else on the page borrows those hues.

09 — someone else's benchmarks

Everything above is scored against our own corpus. These are the first numbers that are not.

Validation loss is a tail of our blend. The per-source losses slice that same blend. The behavioural scores use two prompt sets we wrote. The noise floor comes from our own seed-only control. A model that had learned to imitate this corpus and nothing else would score exactly as well on every one of them.

So the designated current model was run through EleutherAI's lm-evaluation-harness on eight benchmarks built by other people out of data nobody here chose. CPU only, float32, 3,465 seconds. The prediction going in was that it would score at chance on all of them. It does not; the result.

The reference column is measured, not quoted

GPT-2 small was run through the same harness: same lm_eval version, same task definitions, same machine, same day. Quoting its published figures instead would have compared our scores under lm-eval's detokenizers against numbers produced by different code, and it would have concealed the single most useful thing the reference run turned up. It paid for its hour of CPU three times.

It validated the entire setup with one number. Our measured GPT-2 WikiText word perplexity is 37.3695 against the paper's published 37.500.3% apart. The harness, the detokenizer, and the claim that lm-eval's wikitext task really reports WikiText-103 test perplexity are all confirmed by that agreement. That last one is not assumed either: the script loads both dataset configs at run time and confirms their test splits are byte-identical (1,288,493 characters), printing UNVERIFIED on any failure rather than a false confirmation.

It showed where quoting would have misled. The same cross-check disagrees by 29.2% on LAMBADA accuracy — 0.3256 measured against 0.4599 published — because the paper filters stopwords on generation while lm-eval scores the exact continuation by loglikelihood. A published figure and a measured one are different kinds of object. Both are printed, with the gap.

And it showed that three of the eight tasks are dead instruments at this scale, for either model. GPT-2 small is itself at chance on WinoGrande and below chance on ARC-Challenge and MMLU. Read against quoted figures, our three matching rows would have looked like our failures; they are the benchmarks' own floors. A later model that moves WinoGrande or MMLU has done something GPT-2 small could not.

tt-tnt-1024a, 512-token window, against GPT-2 small through the same harness. Headline metric per task; the full report carries 15 metric rows. The chance gate is the noise floor's rule against a different null, so it wears the same four colours. Nothing here asks the model to answer anything: every multiple-choice row is scored by comparing the loglikelihood of the supplied options, which is why a base completion model can be measured on them at all.
Taskscorechance s.e. from chanceverdictGPT-2 small
wikitext word perplexity222.6627no chance baseline37.3695
lambada_openai last word0.0980~0+23.7above chance0.3256
arc_easy0.31060.25+6.4above chance0.4381
piqa0.54840.50+4.2above chance0.6289
hellaswag0.26430.25+3.2above chance0.2892
winogrande0.49960.50−0.0at chance0.5162 at chance
arc_challenge0.17830.25−6.4below chance0.1903 below
mmlu0.22950.25−5.8below chance0.2292 below

What the table says

The model has learned English, not just this corpus. That is the claim no instrument in the repository could reach, because every other one is scored against the same corpus the model was trained on. ARC-Easy at +6.4 standard errors and PIQA at +4.2 — on data nobody here chose, scored by code nobody here wrote, against a null nobody here controls — are what establish it.

That is not a triumph. It is what a 123M-parameter model that has read 352.6M tokens should do, and the distance to the reference class is one number: WikiText-103 perplexity 222.66 against GPT-2 small's 37.37, at 1.2% fewer parameters (122,962,944 against 124,439,808) and 113× less training data (352,714,752 tokens seen, against WebText's ~40 billion). Roughly six times the perplexity for roughly a hundred-and-thirteenth of the data is an ordinary place to sit on the scaling curve. A reader who sees 222 and concludes "broken" is reading the parameter row instead of the token row.

Four things this table does not say

MMLU is not a fair score here, and is flagged rather than averaged in. 524 of its 56,168 requests exceed the 512-token window (longest 1,112), and lm-eval drops tokens off the front of the context, so the model answered 524 questions it was shown only part of. Nothing else came close — outside MMLU and WikiText the longest request in the entire run is 238 tokens. tt-tnt-v3 at a 2048-token window truncates nothing at all, which is what makes the flag a measurement rather than a guess.

Not every above-chance row is solid. Eleven rows carry a chance baseline; if all eleven were truly null, about 0.5 would still clear a 2-standard-error gate by luck. HellaSwag at +3.2 is the weakest of the four and the one to distrust first. ARC-Easy at +6.4, and four rows moving the same way, is not what half an expected false positive looks like.

The 22M model is not far behind on any of it. tt-tnt-v3 scores WikiText 247.32 against 222.66 and LAMBADA 0.0798 against 0.0980, at 5.6× fewer parameters — and it clears WinoGrande where the larger model does not. That is a single-row flip of the kind a multiple-comparison correction eats, and it should not be read as v3 resolving pronouns better. It remains an awkward result for a designation that rests entirely on matched-window validation loss over our own blend.

LAMBADA and the context probe have not been reconciled. LAMBADA requires the last word of a passage and clears chance by 23.7 standard errors, which is real long-range signal. probe_context_use.py finds per-token loss going flat — around position ~128 on the 2048-context checkpoint, around ~64 on the older separator-less one. The two instruments have never been run on the same model: there is no context probe for tt-tnt-1024a. So this is an open question, not a contradiction, and it is written down as one.

Full reports, with all 15 metric rows, the per-task truncation audit, the pinned harness versions and the exact command line: docs/measurements/external-tt-tnt-1024a.md, external-tt-tnt-v3.md, and the reference run external-gpt2-small.md. lm-eval is not a dependency of this repository and did not become one: it lives in a throwaway virtualenv under scratch/ and the script shells out to it. No device was opened.

10 — the embedding space

The vocabulary space has a corpus geography.

A proposed sampler would lay the 32,000-token vocabulary onto Blackhole's Tensix grid and sample by spatial neighbourhood, so that direction on the grid means corpus register. That only works if tokens characteristic of different sources already occupy distinguishable regions of the embedding space. If they do, the layout is discovered. If they do not, any layout is imposed and the claim is decoration. This measurement decides which, before a kernel is written.

It reads model.embed_tokens.weight straight out of model.safetensors and nothing else — no forward pass, no device, CPU only. Characteristic tokens are chosen by log-odds ratio with an informative Dirichlet prior, z-scored (Monroe et al. 2008), 150 per source, winner-take-all so the nine label sets are disjoint. Raw frequency would say nothing, since every source's commonest token is the same function word.

Take a token characteristic of one of the nine sources, look at its ten nearest neighbours by cosine in the embedding space, and 54.6% of them are characteristic of the same source. Chance is 11.1%. The label-permutation floor — the identical computation with the source labels shuffled, which holds the geometry and the class sizes and destroys only the correspondence — is 0.1103 ± 0.0031. The observed value sits 138.8 of those spreads above it. A frequency-only control, built from log corpus count and embedding norm and containing no directional information at all, reaches 0.1350.

A linear probe agrees, and it is the number that speaks to whether a layout is possible at all, because a grid needs source identity to be linearly legible: multinomial logistic regression recovers which of nine sources a token belongs to, from its embedding alone, 77.98% ± 2.31% of the time — against 10.07% with the labels permuted and 20.99% from the frequency-only control. Linear on purpose; a stronger classifier would answer an easier question.

All of the above is the content condition: the same measurement with the 500 globally most frequent tokens excluded. If the geography were a frequency artefact it would collapse there. It strengthens — purity 0.4984 → 0.5458, probe 0.7373 → 0.7798.

Per-source k-NN purity, nine-way content condition, 150 labelled tokens each, k = 10. Chance is 0.111.
Sourcek-NN purityprobe recall
procedural0.7320.867
flavour0.6660.756
poetry0.6130.853
wikipedia_simple0.5890.831
spine0.5490.818
tinystories0.5440.867
weird0.4310.751
folklore0.4090.644
gutenberg_children0.3770.631

Every source is far above chance, but the ordering splits cleanly and the split is the part that matters for a layout. procedural, flavour, poetry and wikipedia_simple occupy well-separated regions. folklore, weird and gutenberg_children share one — they are each other's largest off-diagonal neighbours, folklore and weird trading 0.14 and 0.17 of each other's neighbourhoods. Asking that region for three independent directions would return three draws from the same neighbourhood, which is exactly the failure the proposal exists to avoid.

What it does not show

A "direction" means subject matter and register, not provenance. procedural separates best because its characteristic tokens are food and kitchen words — Ġflour, Ġsugar, Ġboiling — and food words would cluster in any embedding of any corpus. That the clusters align with corpus sources is the finding. That the model represents "which file this came from" is not claimed and was not measured.

The geography is not two-dimensional. The first two principal components of the labelled tokens explain 2.7% and 1.8% of their variance. The report prints a PCA scatter and labels it for looking at only; nothing rests on it. Cosine silhouette is 0.0209 against a permuted floor of −0.0070 ± 0.0004 — positive, far outside its own floor, and small, which is what silhouette does in 1024 dimensions where everything is far from everything.

And it establishes nothing about generated text. This is a measurement of the embedding table, not of behaviour. scripts/score_behaviour.py is where register in actual completions is measured, and what it found is reported further down.

Source: scripts/probe_embedding_geography.py, written to docs/measurements/embedding-geography-tt-tnt-1024a.{md,json}. Model artifacts/hf-tt-tnt-1024a, embedding matrix only. CPU, no device opened, no forward pass run.

11 — a result we withdrew

Comparing a result against the seed-noise floor.

A run with a cosine learning-rate decay tail was compared against its predecessor and looked like an improvement. Several signals moved in the right direction and cleared their paired confidence intervals. It was written up as a finding.

Two controls took it apart. The first was inside the run itself: the decay schedule is held flat for the first 50% of training, so for 5,382 steps the two runs are the same configuration and any difference there is pure run-to-run variance.

Validation loss of the decay run against its predecessor, split by phase. The held-flat phase is the control: during it, nothing is different.
Phasenmean Δ vs. predecessorsem95% CI
held flat (the control)10−0.00780.0087[−0.0249, +0.0093]
decaying12−0.02000.0037[−0.0271, −0.0128]

Read without the control, the decay row is a finding: its interval excludes zero. With the control, decay minus held is −0.0122 ± 0.0095, |t| = 1.29 — not distinguishable from run-to-run noise. The prior was a 0.1–0.3 nat gain. The observed effect is an order of magnitude smaller and unattributable. The schedule really did run — the learning rate is traced falling through the log — so this is the useful form of a negative result, an intervention that applied and did nothing, as distinct from one that silently failed to apply.

The second control was external and blunter: run the whole configuration again, changing nothing but the random seed. The seed reproduced the result. Same four signals, same direction, comparable magnitudes, the same closing line — better on four, worse on none. The finding was withdrawn, and the threshold rule described above exists because of it.

The same table, read twice

This is the clearest demonstration of why the floor has to exist. Below are two comparisons on the same 45-prompt frozen set, scored by the same code. On the left, a 5.6× increase in parameters, with everything else — batch size, context, seed, corpus, optimizer, step count — held. On the right, a different pair of checkpoints entirely: one configuration run twice with nothing changed but the random seed.

Behavioural comparison, prompt set B, 32 completions per prompt over 45 shared prompts. Verdicts as the paired confidence interval alone would call them.
Signal 22M → 123M parameters same config, seed changed
genre collapse rate−0.027 better−0.054 better
— lexical-habit collapse−0.025 better−0.056 better
register margin−0.077 better−0.075 better
nearest source = TinyStories−0.069 better−0.037 better
story-frame collapseno changeno change
termination rateno changeno change
4-gram repeat rateno changeno change
longest repeated spanno changeno change
prompt engagementno changeno change

Better on four, worse on none — twice, on the same four signals. One of those columns is an experiment. The other is a coin. Without the floor there is no way to tell them apart, and a report that showed only the left-hand column would read as a clean win.

The arithmetic is visible in the table itself. The right-hand column is the floor — it is where the floor's numbers come from. So the capacity column's register margin, −0.077, divided by the seed column's −0.075, is 1.03×: NOT INTERPRETABLE, whatever its confidence interval says. The project's own record of which model is current says so in as many words: the register advantage was substantially context, not capacity, and the clean capacity leg is a null by this project's standing rule.

What capacity did buy is real and it is on a different axis: at a matched 512-token window the larger model's validation loss is lower by 0.2994 nats on average and is lower at 22 of 22 checkpoints (sign test p ≈ 4.8e-7), where the seed-only control wanders across sign at 8 of 22. Final matched-window validation loss 2.9281 against 3.1938. Lower loss; no demonstrated change in how it writes.

12 — the regression that mattered

Document separators were absent from the corpus.

Until it was fixed, each document was written into the blend as its text followed by a blank line — which spells a document boundary exactly the way a paragraph break inside a document is spelled. Nothing downstream could tell the two apart, and the tokenizer step dropped blank lines entirely. The result was a corpus containing zero </s> tokens, while every model's config.json nonetheless declared eos_token_id: 2. It was a regression rather than an original absence: the single-source corpus the blend replaced had contained 662,878 of them.

Two consequences, both measured rather than assumed. Generation could never terminate naturally, so it always ran to whatever token limit the caller set. And a position-wise probe showed per-token loss flat from position ~64 onwards — with boundaries unmarked, distant context genuinely is unpredictable, so a model that ignores it is behaving correctly. The mid-generation topic collapses in the sample sheets are the same fact from the other side: the model faithfully reproducing the unmarked document transitions it was trained on.

The fixed blend terminates every document with a line holding exactly </s>. There are 798,771 of them, one per ~500 tokens, 0.200% of the corpus.

Termination on the frozen 15-prompt set, 128 max new tokens, CPU. Before is the last checkpoint trained without separators; after is the first trained with them.
Decodingbeforeafter
greedy0 / 155 / 15
sampled, t = 0.8 / top-p 0.950 / 3011 / 30

The zeros are not "rarely". They are never, by construction. On the larger 45-prompt set the sampled termination rate goes from exactly 0.000 ± 0.000 to 0.051 ± 0.012 — one of the few differences in this project's history large enough that no noise floor is needed to believe it. The two checkpoints either side of this table differ in context length as well as in separators, so most behavioural differences between them are not cleanly attributable. Termination is the exception: the earlier model had never seen the token it was supposed to stop on.

It is a partial fix. Two thirds of completions still run to the limit, and the model is much readier to stop on short-document material than on the book-length sources — which is exactly what the corpus taught it, since separator density ranges from one per ~210 tokens in the short sources to one per ~80,000 in the books.

A hand count, and what the instrument reports

Reading the two sample sheets side by side, someone counted fairy-tale collapse falling from 9 of 15 prompts to 1 of 15, and that figure was written down. The behavioural metric was then built partly to check it — and it did not agree. Overall genre collapse came back no change on both frozen prompt sets. Only the story-frame sub-signal moved, 0.133 ± 0.059 to 0.050 ± 0.024. The per-marker breakdown said why: once_upon_a_time went from 10.8% to 0.0% and little_X_named from 8.3% to 0.4%, while two other markers did not move at all. The project's own conclusion, in its own words: it stopped writing fairy tales but did not stop using fairy-tale words — a narrower improvement than the hand count suggested. The hand count's per-prompt tally was never recorded, so it cannot be audited, and this page does not use it as a number.

The split into frame markers and lexical markers was made after the disagreement was seen, which is the kind of decision that can manufacture the result it was reaching for. The repository says so where it made the split, and keeps the per-marker control table beside it so the choice can be audited rather than trusted.

What the separator regression affected

Three deliberate interventions had been proposed to fix the model's qualitative failures — a sampling change, a stratified validation split, and the learning-rate decay tail. All three were tested. None moved the model. The change that did was a regression none of them had anticipated, in the data rather than in the training.

13 — open, unresolved, or simply not working

What this model still cannot do.

Everything in this section is measured. None of it is hidden in a footnote, because a project that publishes only its wins has not earned the right to be believed about them.

Register, and what moves it

The corpus was blended to pull the model's voice away from children's stories and towards its nineteenth-century naturalist and folklore sources. Three deliberate interventions were aimed at that, and the project's own note records the outcome plainly: a decoding change, a stratified validation split, and a learning-rate decay tail were all tested, and none of them moved the model.

Then something did, and it was not the thing being tested. Filling in the 2×2 of capacity and context showed the register signal moving 3.51× the seed floor between the 22M model and the 123M one — of which almost none is capacity. The clean leg, where two checkpoints share batch size, context, seed, corpus, optimizer and step count and differ only in architecture, moves it 1.03× the floor: NOT INTERPRETABLE, the same magnitude as re-rolling the seed. What moved was the context leg, at 2.48× the floor and 71% of the total — and it moved in the direction nobody expected, register improving as the training window got shorter. That leg varies batch size along with the window, to hold tokens-per-step fixed, so it is not clean either. The honest summary: register is not immovable, capacity is not what moves it, and what does move it has not been isolated.

The voice the blend was aiming at has still not arrived. TinyStories remains the single most common nearest source for the current model's completions at 22.3% — ahead of the eight others, though below its 31% share of the corpus.

A number worth seeing, on the way past: the same models score materially differently on the two frozen prompt sets — nearest-source-is-TinyStories runs 0.45–0.53 on the 15-prompt set and 0.22–0.36 on the 45-prompt one. Same models, same code, different prompts. That is why the sets are never pooled and why both sides of a comparison are always scored on the same one.

On-device generation, and the defect that used to be here

This section previously recorded that the same weights served through the vLLM plugin produced worse output on device than on CPU — greedy decoding collapsing into a repetition loop, local repetition at 1.55× the CPU rate, verdict not usable. That defect was in the vLLM layer, not the model.

Running the same weights, prompts and greedy decoding through tt_transformers directly, with no vLLM, gave a median agreement of 12 tokens with the CPU reference at a local-repeat rate of 0.000, against 4 tokens and 0.222 through vLLM. That located the fault. The plugin was 12 commits behind, including a fix for a sampler returning a stale token when no forward pass was pending — which produces exactly the observed repetition. On plugin c127c17 the local-repeat rate is 0.031 against a CPU reference of 0.000, and free-running decode reads as ordinary prose.

It is back, on a different model and a newer stack (2026-08-19). Serving tt-tnt-1024-dialogue on tt-metal v0.77.0, the local-repeat rate is 0.1125 against a CPU reference of 0.0125 on the same weights and prompts, with one prompt at 0.80 — “The cat. The cat. The cat.” The same bisection localises it the same way, and more sharply than before: driving tt_transformers directly, greedy, no vLLM, gives 0.0000 — cleaner than the CPU reference itself. The prompt that loops worst under vLLM generates “stars. The cat was happy to see the star…” on the same silicon.

Two candidates are already excluded. The plugin, by A/B: serving the same model on c127c17 and bd150c7 gives byte-identical results — every per-prompt rate, every agreement count, every divergence index — which also establishes that the serving path is deterministic across restarts. And multi-block paging, by reducing block_size 64 → 512 so a 512-token context is one block with no page-table traversal: it does not help.

What that refutes precisely is traversal, not paged attention itself — one block still runs the paged code path. Separating those needs the direct arm to page, which currently throws inside paged_fill_cache; that is a harness limitation and not evidence. Left standing: the paged attention op at one block, the scheduler, and the upstream vLLM version itself, about which the plugin A/B says nothing, because it varied the plugin and not vLLM. Open.

Because the answer depends on which path is taken and not on the weights, the fault is somewhere in the decode / KV-cache path. Prefill looks sound — 20 of 21 prompts agree with CPU on the first generated token. A long series of hypotheses has been tested and refuted: the weights, the conversion, the grid patch, MLP quantization, prefix caching, async decode, on-device sampling, KV-cache dtype, and the possibility that it was merely an artifact of greedy decoding. The defect has also survived a change of tokenizer, corpus, corpus revision, context length, weights and tt-metal build. The cause is not known.

Worth noting for anyone gating a serving path: the tt_transformers PCC check passed at 0.9940–0.9998 throughout, because it exercises prefill far harder than long decode.

And the ordinary limitations of a small model

  • It is a base completion model. No instruction tuning, no chat template. Give it the opening of a passage; do not ask it questions.
  • One epoch over one corpus. It has seen its training data once, not memorized it.
  • Under greedy decoding it finds repetition attractors that sampling largely avoids — which is the honest way to read the greedy loops, not an excuse for them.
  • It has no knowledge to speak of. Its idea of physics is weather.