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.
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.
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.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.
-
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.
-
Trained from random init on Blackhole, with
ttmltt-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.yamlso it cannot change under a tt-metal upgrade without a test failing. -
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. -
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.
-
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/modelsreporting the manifest's declared context on the wire. The procedure — and the traps that make it more than one command — is written up indocs/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.
| Source | Emitted tokens | Share | Licence |
|---|---|---|---|
| tinystories | 124,030,364 | 31.046% | CDLA-Sharing-1.0 share-alike |
| gutenberg_children | 59,984,104 | 15.014% | MIT packaging; public-domain texts |
| wikipedia_simple | 59,597,649 | 14.918% | CC-BY-SA-3.0 share-alike |
| spine | 53,915,065 | 13.495% | MIT packaging; public-domain texts |
| procedural | 47,994,272 | 12.013% | MIT packaging; public-domain texts |
| folklore | 32,078,464 | 8.029% | MIT packaging; public-domain texts |
| weird | 15,977,960 | 3.999% | MIT packaging; public-domain texts |
| poetry | 3,950,536 | 0.989% | CC0-1.0 |
| flavour | 1,979,789 | 0.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.
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.| stage | Qwen3-8B via tt_transformers | tt-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 head | 8 × 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 |
| randomness | seeds 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.
| generation seed | own-register lift | z | p (one-sided) |
|---|---|---|---|
| 0 | +0.0928 | +3.20 | 0.00365 |
| 1 | +0.1157 | +4.02 | 0.00070 |
| 2 | +0.1164 | +4.40 | 0.00020 |
| 3 | +0.1049 | +3.74 | 0.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
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.
| Configuration | s / 1000 steps | vs. baseline |
|---|---|---|
| C++ model, one chip — the starting point | 892.9 | 1.00× |
| Python model (null mask), one chip | 770.2 | 1.16× |
| C++ model, four chips | 223.2 | 4.00× |
| Python model (null mask), four chips | 193.4 | 4.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.50 — 0.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.
| Task | score | chance | s.e. from chance | verdict | GPT-2 small |
|---|---|---|---|---|---|
| wikitext word perplexity | 222.6627 | — | — | no chance baseline | 37.3695 |
| lambada_openai last word | 0.0980 | ~0 | +23.7 | above chance | 0.3256 |
| arc_easy | 0.3106 | 0.25 | +6.4 | above chance | 0.4381 |
| piqa | 0.5484 | 0.50 | +4.2 | above chance | 0.6289 |
| hellaswag | 0.2643 | 0.25 | +3.2 | above chance | 0.2892 |
| winogrande | 0.4996 | 0.50 | −0.0 | at chance | 0.5162 at chance |
| arc_challenge | 0.1783 | 0.25 | −6.4 | below chance | 0.1903 below |
| mmlu | 0.2295 | 0.25 | −5.8 | below chance | 0.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.
| Source | k-NN purity | probe recall |
|---|---|---|
| procedural | 0.732 | 0.867 |
| flavour | 0.666 | 0.756 |
| poetry | 0.613 | 0.853 |
| wikipedia_simple | 0.589 | 0.831 |
| spine | 0.549 | 0.818 |
| tinystories | 0.544 | 0.867 |
| weird | 0.431 | 0.751 |
| folklore | 0.409 | 0.644 |
| gutenberg_children | 0.377 | 0.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.
| Phase | n | mean Δ vs. predecessor | sem | 95% CI |
|---|---|---|---|---|
| held flat (the control) | 10 | −0.0078 | 0.0087 | [−0.0249, +0.0093] |
| decaying | 12 | −0.0200 | 0.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.
| 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 collapse | no change | no change |
| termination rate | no change | no change |
| 4-gram repeat rate | no change | no change |
| longest repeated span | no change | no change |
| prompt engagement | no change | no 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.
| Decoding | before | after |
|---|---|---|
| greedy | 0 / 15 | 5 / 15 |
| sampled, t = 0.8 / top-p 0.95 | 0 / 30 | 11 / 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.
14 — everything
Where all of this lives.
Code
github.com/tsingletaryTT/tt-tnt — Apache-2.0, SPDX headers throughout, including every dead end.
Weights
episod/tt-tnt — the served checkpoint, with the model card that is generated from this repository rather than written beside it.
Corpus recipe
episod/tt-tnt-corpus — pinned revisions, the pipeline, the manifest and its SHA-256. Not the text.
Measurements
Every number on this page is in docs/measurements/, in the run's own train.log, or in the .superpowers/ bring-up report the surrounding text names. The frozen prompt sets are docs/evaluation_prompts.json and docs/evaluation_prompts_b.json.
Serving it yourself
docs/serving-with-tt-kernel.md — the bundle, the command that brings a server up, how to confirm you are serving the weights you think you are, and the traps that were found by hitting them. Its known-limitation section is the one to read first.
Upstream asks
docs/upstream-tt-metal-asks.md — four open asks against tt-metal, each with a reproduction and a note on whether it blocks anything. None of them currently blocks.
Where it came from
The Build an LLM from Scratch lesson arc in tt-vscode-toolkit, taken past where the lessons stop.