tern-tc-9m / model.py
playra's picture
tern_tc TC02 checkpoint, C engine, verification harness, 8K tokenizer, model card from model_card.t27
be3fd62 verified
Raw History Blame Contribute Delete
17.6 kB
"""Decoder-only transformer for the IGLA-Coder pilot.
One architecture, two arms that differ only in the linear layers inside the
blocks:
* fp -- ordinary bf16/fp32 linears;
* ternary -- BitNet b1.58 linears: parameter-free RMSNorm on the input,
8-bit absmax activations per token and ternary {-1, 0, +1}
absmean weights, both through straight-through estimators.
Embeddings, the tied output head and the norms stay full precision in both arms.
Layout follows the IGLA-Coder spec (t27 specs/igla/coder/arch.t27): RMSNorm,
RoPE, grouped-query attention, SwiGLU, tied embeddings (t27 #1034 P1.1).
"""
from dataclasses import dataclass, asdict
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
@dataclass
class Config:
vocab_size: int = 32000
n_layer: int = 12
n_head: int = 12
n_kv_head: int = 4
d_model: int = 768
d_ff: int = 2048
seq_len: int = 2048
rope_theta: float = 10000.0
ternary: bool = False
# multiplier-free experiments (research/MULTIPLIER_FREE.md)
wgrad_ternary: bool = False # weight gradient from activations rounded to {-a, 0, +a}
attn_lns: float = 0.0 # >1: attention products on signed powers of this base
lin8: str = "" # 8-bit linear operands: "fmt" or "fwd_fmt:bwd_fmt"
lin8_scale: bool = False # per-tensor amax scaling before rounding
# Ladder sizes: total parameters with vocab 32000 and tied embeddings.
SIZES = {
"13m": dict(n_layer=6, n_head=4, n_kv_head=2, d_model=256, d_ff=704),
"25m": dict(n_layer=8, n_head=6, n_kv_head=2, d_model=384, d_ff=1024),
"52m": dict(n_layer=12, n_head=8, n_kv_head=4, d_model=512, d_ff=1408),
"100m": dict(n_layer=12, n_head=12, n_kv_head=4, d_model=768, d_ff=2048),
# TRI CLAW: ternary blocks sized for the XC7A200T's block RAM (6.45M weights,
# 77% of 13.46 Mb at 1.6 bits); meant for the 8K vocabulary (data8k)
"tc": dict(n_layer=6, n_head=5, n_kv_head=1, d_model=320, d_ff=864),
}
def rms_norm(x, eps=1e-6):
return F.rms_norm(x, (x.size(-1),), eps=eps)
class _TernaryWgrad(torch.autograd.Function):
"""y = x_q W^T as usual; in the backward pass the weight gradient is taken
against activations rounded to three levels per token, so dW = g^T x_t is a
signed sum of gradient rows (the per-token scale is O(d) work, not O(d^2))."""
@staticmethod
def forward(ctx, xq, wq, xt):
ctx.save_for_backward(wq, xt)
ctx.dtypes = (xq.dtype, wq.dtype)
return F.linear(xq, wq)
@staticmethod
def backward(ctx, gy):
wq, xt = ctx.saved_tensors
gx = (gy @ wq.to(gy.dtype)).to(ctx.dtypes[0])
gw = gy.reshape(-1, gy.size(-1)).t() @ xt.reshape(-1, xt.size(-1)).to(gy.dtype)
return gx, gw.to(ctx.dtypes[1]), None
class BitLinear(nn.Linear):
"""BitNet b1.58 linear layer (Ma et al. 2024, training-tips reference code)."""
wgrad_ternary = False
def forward(self, x):
x = rms_norm(x)
s = 127.0 / x.abs().amax(dim=-1, keepdim=True).clamp(min=1e-5)
xq = x + ((x * s).round().clamp(-128, 127) / s - x).detach()
w = self.weight
g = w.abs().mean().clamp(min=1e-5)
w = w + ((w / g).round().clamp(-1, 1) * g - w).detach()
if self.wgrad_ternary:
xd = x.detach()
a = xd.abs().mean(dim=-1, keepdim=True).clamp(min=1e-5)
return _TernaryWgrad.apply(xq, w, (xd / a).round().clamp(-1, 1) * a)
return F.linear(xq, w)
def _fp_magnitudes(e_bits, m_bits, bias, top_nan):
"""Non-negative values of a small IEEE-style float (subnormals included)."""
vals = {0.0}
for m in range(1, 1 << m_bits):
vals.add(m / (1 << m_bits) * 2.0 ** (1 - bias))
for e in range(1, (1 << e_bits) - (0 if top_nan else 1)):
for m in range(1 << m_bits):
if top_nan and e == (1 << e_bits) - 1 and m == (1 << m_bits) - 1:
continue # e4m3: the single NaN code
vals.add((1 + m / (1 << m_bits)) * 2.0 ** (e - bias))
return sorted(vals)
def _tnf_magnitudes(trits, m_bits):
"""Ternary-exponent float packed into 8 binary bits (trinity-fpga
research/block/four_families.py levels()): e in [-(3^t)//2, (3^t)//2]."""
half = 3 ** trits // 2
return sorted({0.0} | {(1 + m / (1 << m_bits)) * 2.0 ** e
for e in range(-half, half + 1) for m in range(1 << m_bits)})
FORMATS8 = {
"e4m3": _fp_magnitudes(4, 3, 7, top_nan=True), # max 448, OCP FP8
"e5m2": _fp_magnitudes(5, 2, 15, top_nan=False), # max 57344, OCP FP8
"tnf8e2": _tnf_magnitudes(2, 3), # 73 values, TNF8 of the paper
"tnf8e3": _tnf_magnitudes(3, 2), # 109 values, GF-T8 / TNF8 at Et=3
}
_TABLES = {}
def _table(name, device):
key = (name, str(device))
if key not in _TABLES:
v = torch.tensor(FORMATS8[name], dtype=torch.float32, device=device)
_TABLES[key] = (v, (v[1:] + v[:-1]) / 2)
return _TABLES[key]
def round8(x, name, scale):
"""Round to the nearest value of an 8-bit format (saturating); optionally after
per-tensor scaling so the tensor's max magnitude maps to the format's max."""
v, mid = _table(name, x.device)
xf = x.float()
s = (v[-1] / xf.abs().amax().clamp(min=1e-30)) if scale else None
y = xf * s if scale else xf
q = torch.sign(y) * v[torch.bucketize(y.abs(), mid)]
return (q / s if scale else q).to(x.dtype)
class _Fake8(torch.autograd.Function):
"""Linear layer whose three matmuls take 8-bit operands: x and W forward,
the output gradient backward (fp32 master weights stay outside)."""
@staticmethod
def forward(ctx, x, w, fwd, bwd, scale):
xq, wq = round8(x, fwd, scale), round8(w, fwd, scale)
ctx.save_for_backward(xq, wq)
ctx.bwd, ctx.scale, ctx.dtypes = bwd, scale, (x.dtype, w.dtype)
return F.linear(xq, wq)
@staticmethod
def backward(ctx, gy):
xq, wq = ctx.saved_tensors
g = round8(gy, ctx.bwd, ctx.scale)
gx = (g @ wq.to(g.dtype)).to(ctx.dtypes[0])
gw = g.reshape(-1, g.size(-1)).t() @ xq.reshape(-1, xq.size(-1)).to(g.dtype)
return gx, gw.to(ctx.dtypes[1]), None, None, None
class Fake8Linear(nn.Linear):
fmt_fwd, fmt_bwd, scale = "e4m3", "e5m2", False
def forward(self, x):
return _Fake8.apply(x, self.weight, self.fmt_fwd, self.fmt_bwd, self.scale)
def lns_round(x, base):
"""Nearest signed power of `base`, rounded in the log domain, as an 8-bit
code would hold it (sign + 7-bit exponent in [-96, 31]); tiny values -> 0.
base == 1 returns x unchanged (used to test the attention path)."""
if base == 1.0:
return x
mag = x.abs().float()
e = torch.round(torch.log(mag.clamp(min=1e-38)) / math.log(base))
q = torch.sign(x.float()) * torch.pow(base, e.clamp(-96, 31))
return torch.where(e < -96, torch.zeros_like(q), q).to(x.dtype)
def _lns_q(x, inv_lb, lb):
"""lns_round for float32 tensors with precomputed 1/log(base), log(base)."""
e = torch.round(torch.log(x.abs().clamp(min=1e-38)) * inv_lb)
q = torch.sign(x) * torch.exp(e.clamp(-96, 31) * lb)
return torch.where(e < -96, torch.zeros_like(q), q)
def _causal_mask(T, device):
return torch.ones(T, T, dtype=torch.bool, device=device).triu(1)
def _lns_attn_fwd(q, k, v, inv_lb, lb, scale):
qs, ks, vs = (_lns_q(t.float(), inv_lb, lb) for t in (q, k, v))
s = (qs @ ks.transpose(-1, -2)) * scale
p = torch.softmax(s.masked_fill(_causal_mask(s.size(-1), s.device), float("-inf")), dim=-1)
return _lns_q(p, inv_lb, lb) @ vs, qs, ks, vs
def _lns_attn_bwd(qs, ks, vs, go, inv_lb, lb, scale):
s = (qs @ ks.transpose(-1, -2)) * scale
p = torch.softmax(s.masked_fill(_causal_mask(s.size(-1), s.device), float("-inf")), dim=-1)
ps, gos = _lns_q(p, inv_lb, lb), _lns_q(go.float(), inv_lb, lb)
gv = ps.transpose(-1, -2) @ gos
gp = gos @ vs.transpose(-1, -2)
gs = _lns_q(p * (gp - (gp * p).sum(-1, keepdim=True)) * scale, inv_lb, lb)
return gs @ ks, gs.transpose(-1, -2) @ qs, gv
_lns_attn_fwd_c = torch.compile(_lns_attn_fwd, dynamic=False)
_lns_attn_bwd_c = torch.compile(_lns_attn_bwd, dynamic=False)
class _LNSAttention(torch.autograd.Function):
"""Causal attention whose six matrix products (two forward, four backward)
take operands rounded to signed powers of `base`, as a logarithmic-number
datapath would (a product there is an exponent addition). Accumulation and
softmax stay exact, i.e. an ideal wide accumulator; P is recomputed.
base == 1 skips the rounding (used to test the attention path)."""
@staticmethod
def forward(ctx, q, k, v, base):
scale = q.size(-1) ** -0.5
lb = math.log(base) if base != 1.0 else 0.0
inv = 1.0 / lb if lb else 0.0
fwd = _lns_attn_fwd_c if q.is_cuda else _lns_attn_fwd
if base == 1.0:
qs, ks, vs = q.float(), k.float(), v.float()
p = _causal_probs(qs, ks, scale)
out = p @ vs
else:
out, qs, ks, vs = fwd(q, k, v, inv, lb, scale)
ctx.save_for_backward(qs, ks, vs)
ctx.base, ctx.scale, ctx.lb, ctx.inv, ctx.dtype = base, scale, lb, inv, q.dtype
return out.to(q.dtype)
@staticmethod
def backward(ctx, go):
qs, ks, vs = ctx.saved_tensors
if ctx.base == 1.0:
p = _causal_probs(qs, ks, ctx.scale)
g = go.float()
gv = p.transpose(-1, -2) @ g
gp = g @ vs.transpose(-1, -2)
gs = p * (gp - (gp * p).sum(-1, keepdim=True)) * ctx.scale
gq, gk = gs @ ks, gs.transpose(-1, -2) @ qs
else:
bwd = _lns_attn_bwd_c if go.is_cuda else _lns_attn_bwd
gq, gk, gv = bwd(qs, ks, vs, go, ctx.inv, ctx.lb, ctx.scale)
return gq.to(ctx.dtype), gk.to(ctx.dtype), gv.to(ctx.dtype), None
def _causal_probs(q, k, scale):
s = (q.float() @ k.float().transpose(-1, -2)) * scale
return torch.softmax(s.masked_fill(_causal_mask(s.size(-1), s.device), float("-inf")), dim=-1)
@torch.compiler.disable
def lns_attention(q, k, v, base):
"""Opaque to the model-level compile; the helpers inside are compiled on their own."""
return _LNSAttention.apply(q, k, v, base)
def rope_cache(seq_len, head_dim, theta, device):
inv = 1.0 / theta ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim)
t = torch.arange(seq_len, device=device).float()
freqs = torch.outer(t, inv)
return freqs.cos(), freqs.sin()
def apply_rope(x, cos, sin):
# x: (B, T, H, D); cos/sin: (T, D/2)
x1, x2 = x.float().chunk(2, dim=-1)
cos, sin = cos[None, :, None, :], sin[None, :, None, :]
return torch.cat([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1).type_as(x)
class Attention(nn.Module):
def __init__(self, cfg, Lin):
super().__init__()
self.lns = cfg.attn_lns
self.h, self.kv = cfg.n_head, cfg.n_kv_head
self.hd = cfg.d_model // cfg.n_head
d = cfg.d_model
self.q = Lin(d, self.h * self.hd, bias=False)
self.k = Lin(d, self.kv * self.hd, bias=False)
self.v = Lin(d, self.kv * self.hd, bias=False)
self.o = Lin(self.h * self.hd, d, bias=False)
def forward(self, x, cos, sin, cache=None):
B, T, _ = x.shape
q = apply_rope(self.q(x).view(B, T, self.h, self.hd), cos, sin).transpose(1, 2)
k = apply_rope(self.k(x).view(B, T, self.kv, self.hd), cos, sin).transpose(1, 2)
v = self.v(x).view(B, T, self.kv, self.hd).transpose(1, 2)
if cache is not None:
k, v = cache.update(k, v)
# A single-token decode step attends to the whole cache; everything else is causal.
causal = T > 1
if self.kv != self.h:
k = k.repeat_interleave(self.h // self.kv, dim=1)
v = v.repeat_interleave(self.h // self.kv, dim=1)
if self.lns > 1 and cache is None:
y = lns_attention(q, k, v, self.lns)
else:
y = F.scaled_dot_product_attention(q, k, v, is_causal=causal)
return self.o(y.transpose(1, 2).reshape(B, T, self.h * self.hd))
class MLP(nn.Module):
def __init__(self, cfg, Lin):
super().__init__()
self.gate = Lin(cfg.d_model, cfg.d_ff, bias=False)
self.up = Lin(cfg.d_model, cfg.d_ff, bias=False)
self.down = Lin(cfg.d_ff, cfg.d_model, bias=False)
def forward(self, x):
return self.down(F.silu(self.gate(x)) * self.up(x))
class Block(nn.Module):
def __init__(self, cfg, Lin):
super().__init__()
self.n1 = nn.RMSNorm(cfg.d_model, eps=1e-6)
self.attn = Attention(cfg, Lin)
self.n2 = nn.RMSNorm(cfg.d_model, eps=1e-6)
self.mlp = MLP(cfg, Lin)
def forward(self, x, cos, sin, cache=None):
x = x + self.attn(self.n1(x), cos, sin, cache)
return x + self.mlp(self.n2(x))
class KVCache:
"""Per-layer key/value buffers for batched generation of equal-length prompts."""
def __init__(self, B, kv_heads, head_dim, max_len, device, dtype):
self.k = torch.zeros(B, kv_heads, max_len, head_dim, device=device, dtype=dtype)
self.v = torch.zeros_like(self.k)
self.pos = 0
def update(self, k, v):
T = k.size(2)
self.k[:, :, self.pos:self.pos + T] = k
self.v[:, :, self.pos:self.pos + T] = v
self.pos += T
return self.k[:, :, :self.pos], self.v[:, :, :self.pos]
class GPT(nn.Module):
def __init__(self, cfg: Config):
super().__init__()
self.cfg = cfg
Lin = BitLinear if cfg.ternary else Fake8Linear if cfg.lin8 else nn.Linear
self.wte = nn.Embedding(cfg.vocab_size, cfg.d_model)
self.blocks = nn.ModuleList(Block(cfg, Lin) for _ in range(cfg.n_layer))
self.norm = nn.RMSNorm(cfg.d_model, eps=1e-6)
cos, sin = rope_cache(cfg.seq_len * 2, cfg.d_model // cfg.n_head, cfg.rope_theta, "cpu")
self.register_buffer("cos", cos, persistent=False)
self.register_buffer("sin", sin, persistent=False)
self.apply(self._init)
fwd8, _, bwd8 = cfg.lin8.partition(":")
for m in self.modules():
if isinstance(m, BitLinear):
m.wgrad_ternary = cfg.wgrad_ternary
if isinstance(m, Fake8Linear):
m.fmt_fwd, m.fmt_bwd, m.scale = fwd8, bwd8 or fwd8, cfg.lin8_scale
for name, p in self.named_parameters():
if name.endswith(("attn.o.weight", "mlp.down.weight")):
nn.init.normal_(p, std=0.02 / math.sqrt(2 * cfg.n_layer))
@staticmethod
def _init(m):
if isinstance(m, (nn.Linear, nn.Embedding)):
nn.init.normal_(m.weight, std=0.02)
def forward(self, idx, targets=None, caches=None):
T = idx.size(1)
start = caches[0].pos if caches else 0
cos, sin = self.cos[start:start + T], self.sin[start:start + T]
x = self.wte(idx)
for i, blk in enumerate(self.blocks):
x = blk(x, cos, sin, caches[i] if caches else None)
logits = F.linear(self.norm(x), self.wte.weight)
if targets is None:
return logits
loss = F.cross_entropy(logits.float().view(-1, logits.size(-1)), targets.reshape(-1),
reduction="none")
return loss.view(targets.shape)
def num_params(self):
return sum(p.numel() for p in self.parameters())
def flops_per_token(self, seq_len=None):
"""Training FLOPs per token: 6 * matmul params + causal attention (Kaplan 2020).
Every parameter except the norms takes part in a matmul (the tied
embedding matrix does so as the output head)."""
c = self.cfg
norms = sum(p.numel() for m in self.modules() if isinstance(m, nn.RMSNorm)
for p in m.parameters())
return 6 * (self.num_params() - norms) + 6 * c.n_layer * (seq_len or c.seq_len) * c.d_model
@torch.no_grad()
def generate(self, prompt, max_new, temperature=0.2, top_p=0.95, cache_dtype=torch.float32,
stop_fn=None, check_every=16):
"""Sample continuations for a batch of equal-length prompts (B, T).
stop_fn(tokens) is called every `check_every` steps with the tokens
generated so far; returning True ends generation early."""
B, T = prompt.shape
c = self.cfg
caches = [KVCache(B, c.n_kv_head, c.d_model // c.n_head, T + max_new, prompt.device,
cache_dtype) for _ in range(c.n_layer)]
logits = self(prompt, caches=caches)[:, -1]
out = []
for i in range(max_new):
if temperature > 0:
probs = torch.softmax(logits.float() / temperature, dim=-1)
sp, si = probs.sort(dim=-1, descending=True)
sp[sp.cumsum(-1) - sp > top_p] = 0
nxt = si.gather(-1, torch.multinomial(sp / sp.sum(-1, keepdim=True), 1))
else:
nxt = logits.argmax(-1, keepdim=True)
out.append(nxt)
if stop_fn and (i + 1) % check_every == 0 and stop_fn(torch.cat(out, dim=1)):
break
logits = self(nxt, caches=caches)[:, -1]
return torch.cat(out, dim=1)
def build(size, ternary=False, vocab_size=32000, seq_len=2048, **extra):
return GPT(Config(vocab_size=vocab_size, seq_len=seq_len, ternary=ternary, **SIZES[size],
**extra))
def config_dict(model):
return asdict(model.cfg)