August 28, 2026 · 12 min read · Code included
Can a hybrid architecture beat the Transformer?
The Transformer — the design behind nearly every LLM — re-reads everything it has seen for every new word. That works brilliantly, but it gets expensive. We built two alternatives that carry a running memory alongside attention, trained each at 100 million parameters on 2 billion tokens, and raced them against an identical Transformer. Here is what happened, what it means, and every line of code to repeat it.
⚡ The 2-minute version
- We built two new model designs — TAM v3 and Cortex-S — that give a language model a persistent memory instead of pure attention.
- Both beat a matched Transformer at predicting text (lower loss and perplexity) at every size we tested: 25M, 50M and 100M parameters.
- But predicting text is not the whole story: the Transformer is about a third faster to train and still wins most downstream quiz-style benchmarks. Hybrids show promise; they are not a breakthrough yet.
- Everything below is reproducible — the full training code ships inline in this article. No hidden repositories.
The idea in one picture
A standard Transformer answers each new word by re-reading all previous words — accurate, but the cost grows with every token. Our hybrids add a second pathway: a compact world-state, a running notebook the model updates token by token and can consult instantly.
🔁 Classic Transformer LLM
Every layer attends to every past token. Nothing is ever forgotten — but nothing is ever summarized either, so long contexts get slow and expensive.
🧠 TAM v3 / Cortex-S hybrid
Attention runs alongside a learned recurrent memory that compresses the past into a fixed-size state. Less re-reading, cheaper long contexts — if the memory learns to keep the right things.
The two challengers
TAM v3 — Temporal Associative Memory
Each block runs reduced-width causal attention and a diagonal affine scan (the recurrent world-state) in parallel. A learned scalar gate mixes the two:
output = 2 × ((1−g) × attention + g × world_state)
The scan is parallel-associative, so training stays fast. The gate learns to route about 55% through the world-state branch and 45% through attention.
Cortex-S — Safe Recurrent MoE
Sparse mixture-of-experts (8 experts, top-2 routing) with persistent recurrent state (128 values per layer). Full attention runs only every 6th layer — the rest use the recurrent pathway.
Includes a deterministic safety kernel boundary: every inference step is auditable and compute-bounded by construction.
🔬 A fair fight: matched experimental design
The only difference between runs is the architecture itself — same size, same data, same hardware, same starting seed:
Parameters — matched within 0.003%
TAM: 101,806,616 · Transformer: 101,803,520
Tokens — identical byte-for-byte
FineWeb-Edu, FineMath, StackV2, Cosmopedia, ArXiv
Hardware — pinned GPU class
bfloat16 precision, torch.compile, AdamW
Same seed — identical initialization
Same optimizer, LR schedule, batch size
Results at 100M / 2B tokens
Lower loss (NLL) and lower perplexity (PPL) mean the model predicts text better. TAM v3 wins both.
Full pretraining + post-training comparison
| Metric | TAM v3 | Transformer | Winner |
|---|---|---|---|
| Pretrain NLL ↓ | 2.6984 | 2.7116 | TAM |
| Pretrain perplexity ↓ | 14.86 | 15.05 | TAM |
| SFT assistant NLL ↓ | 1.7839 | 1.7947 | TAM |
| DPO reward accuracy ↑ | 62.0% | 62.6% | TFM |
| Final mixture NLL ↓ | 2.8439 | 2.8647 | TAM |
| Throughput (tok/s) ↑ | ~254.8k | ~319.9k | TFM |
| Wall-clock time ↓ | ~9,196s | ~6,917s | TFM |
NLL = negative log-likelihood (lower is better). PPL = perplexity (lower is better). Both post-trained with SFT on SmolSmolTalk (100K examples) + DPO on UltraFeedback (10K pairs). Context length 512.
Scaling: TAM wins at every size
Before the full run, TAM v3 was screened at 25M and 50M parameters (3 seeds each, 10M tokens). It won on loss every single time — and the gap grew from 25M to 50M.
| Scale | TAM v3 NLL | Transformer NLL | TAM wins | Throughput ratio |
|---|---|---|---|---|
| 25M10M tokens · 3 seeds | 7.162 | 7.207 | 3 / 3 | ~86% |
| 50M10M tokens · 3 seeds | 6.889 | 7.064 | 3 / 3 | ~82% |
| 100M2B tokens · 1 seed | 2.698 | 2.712 | 1 / 1 | ~80% |
Downstream quizzes: the Transformer strikes back
On standard multiple-choice benchmarks the Transformer wins 4 out of 5 — a known small-scale phenomenon: better text prediction does not always mean better quiz answers yet.
| Benchmark | TAM v3 | Transformer | Delta |
|---|---|---|---|
| ARC-Easy | 31.5% | 35.5% | −4.0 pp |
| ARC-Challenge | 26.0% | 25.5% | +0.5 pp |
| PIQA | 54.0% | 58.0% | −4.0 pp |
| HellaSwag | 27.5% | 29.5% | −2.0 pp |
| OpenBookQA | 28.5% | 29.5% | −1.0 pp |
| GSM8K | 2.0% | 2.0% | 0.0 pp |
| Five-MCQ mean | 33.5% | 35.6% | −2.1 pp |
Memory probes: where the world-state shines
Four synthetic tasks (delayed recall, associative recall, state tracking, needle retrieval) at seven context lengths, 96 paired trials each. TAM's biggest win lands at 256 tokens — exactly where a running memory helps most.
| Context length | TAM − Transformer | 95% CI | Signal |
|---|---|---|---|
| 128 | −2.08 pp | −7.03 to +2.60 | |
| 256 | +7.81 pp | +2.60 to +13.54 | Significant |
| 384 | +2.86 pp | −2.60 to +8.33 | |
| 512 | −0.52 pp | −5.73 to +4.69 | |
| 640 | −1.04 pp | −6.51 to +4.69 | |
| 768 | −0.78 pp | −6.25 to +4.69 | |
| 1000 | +1.04 pp | −4.43 to +6.51 | |
| Aggregate | +1.04 pp | −1.00 to +3.05 |
At 256 tokens, TAM's state tracking was +11.5 pp and needle retrieval +12.5 pp over the Transformer. The edge fades at longer contexts where full attention already captures the dependencies.
Cortex-S confirms it is not a fluke
A completely separate design on the same frozen 2B-token corpus — and it also beats the Transformer on loss.
| Metric | Cortex-S | Transformer |
|---|---|---|
| Parameters | 101,778,112 | 101,803,520 |
| Final NLL ↓ | 2.7091 | 2.7116 |
| Final perplexity ↓ | 15.015 | 15.054 |
| Architecture | Sparse MoE + recurrent | Standard causal attention |
Cortex-S: 8 experts (top-2 routing), 24 layers with full attention only every 6th layer, persistent state of size 128 per layer, deterministic safety kernel on every step.
Honest verdict
TAM v3 is not a breakthrough — but the signal is real.
- ✅Lower loss at every scale tested (25M, 50M, 100M) — the recurrent state genuinely helps language modelling.
- ✅Significant memory advantage at 256 tokens — the world-state carries information that attention alone drops.
- ✅Two independent architectures (TAM + Cortex-S) both beat the Transformer on loss — not a fluke of one design.
But:
- ❌The Transformer is about a third faster in wall-clock time. Per unit of compute, it may still win.
- ❌The loss advantage did not transfer to quiz benchmarks — the Transformer wins 4 out of 5.
- ❌No broad long-context advantage beyond 512 tokens — persistent state alone is not enough.
Next step: TAM v4 (persistent cross-chunk state, novelty write gate, dynamic routing) gets tested at small scale before any more GPU budget is committed. Scaling TAM v3 past 100M is not justified on these numbers.
Training protocol
Data
FineWeb-Edu (45%), FineMath (17.5%), StackV2 (15%), Cosmopedia (15%), ArXiv (7.5%) — 2B tokens, GPT-2 tokenizer, context 512
Optimizer
AdamW, beta1 0.9, beta2 0.95, LR 3e-4 cosine, weight decay 0.1, gradient clip 1.0
Batch
Micro-batch 64 by gradient accumulation 2 gives effective batch 128, bfloat16 mixed precision
Post-training
SFT on SmolSmolTalk (100K examples), then DPO on UltraFeedback (10K preference pairs)
Replicate it: the full training code
Everything below is the exact protocol from this paper, written as runnable reference code. Train the hybrid, train the baseline, compare them yourself — on our numbers or your own data. The 2B-token runs used a fused associative-scan kernel; the loop below is mathematically identical and easier to follow.
0Requirements and launch
pip install torch==2.3.* datasets transformers tqdm numpy python prepare_data.py --out ./shards --tokens 2000000000 python train_hybrid.py --arch tam --shards ./shards python train_hybrid.py --arch transformer --shards ./shards python compare_runs.py --a ./out/tam --b ./out/transformer
1Build the data shards — stream the five sources with the paper weights, GPT-2 tokenize, pack to context 512 plus one label token.
# Documented mix: FineWeb-Edu 45, FineMath 17.5, StackV2 15, # Cosmopedia 15, ArXiv 7.5. Point DATASETS at your local mirrors. import argparse, numpy as np from datasets import load_dataset, interleave_datasets from transformers import GPT2TokenizerFast CTX = 512 MIX = [ ("fineweb-edu", 0.450), ("finemath", 0.175), ("stack-v2", 0.150), ("cosmopedia", 0.150), ("arxiv", 0.075), ] def main(): ap = argparse.ArgumentParser() ap.add_argument("--out", required=True) ap.add_argument("--tokens", type=int, default=2_000_000_000) ap.add_argument("--seed", type=int, default=8100) args = ap.parse_args() tok = GPT2TokenizerFast.from_pretrained("gpt2") streams = [load_dataset(name, split="train", streaming=True) for name, _ in MIX] probs = [w for _, w in MIX] data = interleave_datasets(streams, probabilities=probs, seed=args.seed) buf, written, shard = [], 0, 0 for row in data: buf.extend(tok(row["text"])["input_ids"]) while len(buf) >= CTX + 1: seq = np.array(buf[:CTX + 1], dtype=np.uint16) seq.tofile(f"{args.out}/shard_{shard:05d}.bin") buf = buf[CTX + 1:] written += CTX + 1 shard += 1 if written >= args.tokens: break print(f"wrote {written} tokens in {shard} shards") if __name__ == "__main__": main()
2Train TAM v3 or the matched Transformer — same size, same seed, same everything except the block. Effective batch 128 (micro 64 by accumulation 2), AdamW with cosine decay, bfloat16.
import argparse, glob, math import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from tqdm import tqdm D_MODEL, N_LAYERS, N_HEADS, CTX = 768, 12, 12, 512 VOCAB, SEED = 50257, 8100 # GPT-2 vocab, paper seed class CausalAttention(nn.Module): def __init__(self, d, heads): super().__init__() self.qkv = nn.Linear(d, 3 * d, bias=False) self.proj = nn.Linear(d, d, bias=False) self.heads = heads def forward(self, x): B, T, D = x.shape q, k, v = self.qkv(x).chunk(3, dim=-1) def split(t): r = t.view(B, T, self.heads, D // self.heads) return r.transpose(1, 2) q, k, v = split(q), split(k), split(v) y = F.scaled_dot_product_attention(q, k, v, is_causal=True) y = y.transpose(1, 2).reshape(B, T, D) return self.proj(y) class TAMBlock(nn.Module): """Reduced-width attention in parallel with a diagonal affine scan (the world-state), mixed by a learned gate: out = 2 * ((1 - g) * attn + g * state).""" def __init__(self, d, heads): super().__init__() self.attn = CausalAttention(d, heads) self.decay = nn.Parameter(torch.zeros(d)) self.in_proj = nn.Linear(d, d, bias=False) self.gate = nn.Parameter(torch.zeros(())) self.norm = nn.LayerNorm(d) def scan(self, u): """Diagonal recurrence s = alpha * s + u, one step per token. Paper runs use a fused associative-scan kernel; this loop is mathematically identical and easier to audit.""" alpha = torch.exp(-F.softplus(self.decay)) # (d,) s = torch.zeros_like(u[:, :1]) outs = [] for t in range(u.size(1)): s = alpha * s + u[:, t:t + 1] outs.append(s) return torch.cat(outs, dim=1) def forward(self, x): h = self.norm(x) a = self.attn(h) s = self.scan(self.in_proj(h)) g = torch.sigmoid(self.gate) return x + 2 * ((1 - g) * a + g * s) class TransformerBlock(nn.Module): """Matched baseline: full-width causal attention plus MLP.""" def __init__(self, d, heads): super().__init__() self.attn = CausalAttention(d, heads) self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d)) self.n1 = nn.LayerNorm(d) self.n2 = nn.LayerNorm(d) def forward(self, x): x = x + self.attn(self.n1(x)) return x + self.mlp(self.n2(x)) class LM(nn.Module): def __init__(self, arch): super().__init__() Block = TAMBlock if arch == "tam" else TransformerBlock self.emb = nn.Embedding(VOCAB, D_MODEL) self.pos = nn.Embedding(CTX, D_MODEL) self.blocks = nn.ModuleList([Block(D_MODEL, N_HEADS) for _ in range(N_LAYERS)]) self.norm = nn.LayerNorm(D_MODEL) self.head = nn.Linear(D_MODEL, VOCAB, bias=False) def forward(self, idx): B, T = idx.shape pos = torch.arange(T, device=idx.device).unsqueeze(0) x = self.emb(idx) + self.pos(pos) for blk in self.blocks: x = blk(x) return self.head(self.norm(x)) def shard_stream(path): for f in sorted(glob.glob(path + "/*.bin")): raw = np.fromfile(f, dtype=np.uint16).astype(np.int64) for i in range(0, len(raw) - CTX, CTX + 1): yield raw[i:i + CTX + 1] def main(): ap = argparse.ArgumentParser() ap.add_argument("--arch", choices=["tam", "transformer"]) ap.add_argument("--shards", required=True) ap.add_argument("--out", default=None) args = ap.parse_args() out = args.out or f"./out/{args.arch}" torch.manual_seed(SEED) torch.set_float32_matmul_precision("high") dev = "cuda" if torch.cuda.is_available() else "cpu" model = torch.compile(LM(args.arch).to(dev, dtype=torch.bfloat16)) opt = torch.optim.AdamW(model.parameters(), lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1) MICRO, ACCUM = 64, 2 # effective batch 128 total_steps = 2_000_000_000 // (MICRO * ACCUM * CTX) sched = torch.optim.lr_scheduler.LambdaLR( opt, lambda s: 0.5 * (1 + math.cos(math.pi * s / total_steps))) stream, step, running = shard_stream(args.shards), 0, 0.0 model.train() for seq in tqdm(stream, total=total_steps * ACCUM): idx = torch.tensor(seq, dtype=torch.long, device=dev) idx = idx.unsqueeze(0).expand(MICRO, CTX + 1) with torch.autocast(dev, dtype=torch.bfloat16): loss = F.cross_entropy( model(idx[:, :CTX]).reshape(-1, VOCAB), idx[:, 1:].reshape(-1)) / ACCUM loss.backward() running += loss.item() if (step + 1) % ACCUM == 0: nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() sched.step() opt.zero_grad() nll = running print(f"opt-step {(step + 1) // ACCUM} NLL {nll:.4f} " f"PPL {math.exp(nll):.2f}", flush=True) running = 0.0 step += 1 torch.save(model.state_dict(), out + "/final.pt") if __name__ == "__main__": main()
3Cortex-S variant — swap the block for sparse MoE plus persistent state, with full attention only every 6th layer. Same script, same protocol otherwise.
import torch import torch.nn as nn import torch.nn.functional as F from train_hybrid import CausalAttention N_EXPERTS, TOP_K, STATE = 8, 2, 128 class SparseMoE(nn.Module): """8 feed-forward experts, top-2 routing per token.""" def __init__(self, d): super().__init__() self.gate = nn.Linear(d, N_EXPERTS, bias=False) self.experts = nn.ModuleList( [nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d)) for _ in range(N_EXPERTS)]) def forward(self, x): w = F.softmax(self.gate(x), dim=-1) topw, topi = w.topk(TOP_K, dim=-1) topw = topw / topw.sum(dim=-1, keepdim=True) out = torch.zeros_like(x) for k in range(TOP_K): for j, expert in enumerate(self.experts): pick = (topi[..., k] == j).unsqueeze(-1) if pick.any(): out = out + pick * topw[..., k:k + 1] * expert(x) return out class CortexSBlock(nn.Module): """MoE plus a persistent recurrent state. Full attention only when layer_id % 6 == 0; other layers ride the state. The state update is bounded, so every step is auditable.""" def __init__(self, d, heads, layer_id): super().__init__() self.layer_id = layer_id self.attn = CausalAttention(d, heads) if layer_id % 6 == 0 else None self.moe = SparseMoE(d) self.to_state = nn.Linear(d, STATE, bias=False) self.from_state = nn.Linear(STATE, d, bias=False) self.norm = nn.LayerNorm(d) def forward(self, x, state): """state: (B, STATE) carried across the sequence.""" h = self.norm(x) if self.attn is not None: h = h + self.attn(h) h = h + self.moe(h) # bounded write: tanh keeps the state in a fixed range write = torch.tanh(self.to_state(h).mean(dim=1)) state = 0.9 * state + 0.1 * write read = self.from_state(state).unsqueeze(1) return x + h + read, state
Post-training follows the same recipe for every run: supervised fine-tuning on 100K assistant examples, then preference optimization on 10K pairs — same tokenizer, same seeds. Evaluate with NLL and perplexity on a held-out slice before trusting any downstream quiz.
The code above reproduces our runs end to end. If your variant beats TAM v3 on loss and downstream — we want to hear about it at hej@belna.se.