trit-embed-3l / trit_embed.py
Cyronius's picture
trit-embed-3l: ternary 3-layer sentence embedder
6246abf verified
Raw History Blame Contribute Delete
7.71 kB
"""Trit sentence embedder: 3 post-norm BERT layers, width 384, BitNet b1.58 weights, int8 activations.
Reference inference code for the packed release format (config "format": "trit-packed-v1"):
- every linear layer inside the encoder is stored as 2-bit ternary codes plus one bf16 scale per output row;
- the word and position embedding tables are int8 with one fp32 scale per row;
- LayerNorms, biases, the token-type embedding and the 384x384 output projection are fp32.
from trit_embed import TritEmbed
model = TritEmbed.from_pretrained(".")
vectors = model.encode(["how do index funds work", "Index funds track a market index."]) # (2, 384), unit norm
PyTorch has no ternary matmul, so this decodes the layers to fp32 once at load and emulates the int8 activation
rounding in float. The NPU encoder (phlegm, open_kernels/designs/trit_embed) runs the 2-bit codes natively.
"""
import json
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
GROUP = 256 # channels per activation scale in the NPU kernels
def ste(x: torch.Tensor, q: torch.Tensor) -> torch.Tensor:
return x + (q - x).detach()
def quant_act(x: torch.Tensor) -> torch.Tensor:
"""int8 per token (per 256-channel block when the width divides), power-of-two scale."""
shape = x.shape
if shape[-1] > GROUP and shape[-1] % GROUP == 0:
x = x.reshape(*shape[:-1], shape[-1] // GROUP, GROUP)
m = x.detach().float().abs().amax(-1, keepdim=True).clamp(min=1e-5)
f, k = torch.frexp(m)
s = torch.ldexp(torch.ones_like(m), k - 7 + (2 * f > 127 / 64).to(k.dtype))
q = ((x.detach().float() / s).round().clamp(-128, 127) * s).to(x.dtype)
return ste(x, q).reshape(shape)
def pack_codes(codes: torch.Tensor) -> torch.Tensor:
"""Ternary codes [out, in] -> uint8 [out, in/4]: code + 1 in {0, 1, 2}, column 4b + j at bits 2j..2j+1."""
c = (codes.to(torch.int16) + 1).to(torch.uint8).reshape(codes.shape[0], -1, 4)
return c[..., 0] | (c[..., 1] << 2) | (c[..., 2] << 4) | (c[..., 3] << 6)
def unpack_codes(packed: torch.Tensor) -> torch.Tensor:
"""uint8 [out, in/4] -> int8 ternary codes [out, in]."""
shifts = torch.tensor([0, 2, 4, 6], dtype=torch.uint8)
c = (packed.unsqueeze(-1) >> shifts) & 3
if (c == 3).any():
raise ValueError("Reserved 2-bit ternary code 3")
return (c.to(torch.int8) - 1).reshape(packed.shape[0], -1)
class TernLinear(nn.Module):
"""y = int8(x) @ (codes * scale).T + bias. codes in {-1, 0, 1}; scale is one bf16 value per output row."""
def __init__(self, n_in: int, n_out: int):
super().__init__()
self.register_buffer("codes", torch.zeros(n_out, n_in, dtype=torch.int8))
self.register_buffer("scale", torch.zeros(n_out, dtype=torch.bfloat16))
self.register_buffer("weight", torch.zeros(n_out, n_in), persistent=False)
self.bias = nn.Parameter(torch.zeros(n_out))
def decode(self) -> None:
self.weight = self.codes.float() * self.scale.float()[:, None]
def forward(self, x: torch.Tensor) -> torch.Tensor:
return F.linear(quant_act(x), self.weight, self.bias)
class Int8Embedding(nn.Module):
"""Lookup table stored as int8 rows with one fp32 scale per row."""
def __init__(self, rows: int, dim: int):
super().__init__()
self.register_buffer("qweight", torch.zeros(rows, dim, dtype=torch.int8))
self.register_buffer("scale", torch.zeros(rows))
def forward(self, ids: torch.Tensor) -> torch.Tensor:
return self.qweight[ids].float() * self.scale[ids].unsqueeze(-1)
class Layer(nn.Module):
def __init__(self, d: int, heads: int, ffn: int, eps: float, gelu: str):
super().__init__()
self.heads, self.gelu = heads, gelu
self.q, self.k, self.v, self.o = (TernLinear(d, d) for _ in range(4))
self.ln1 = nn.LayerNorm(d, eps=eps)
self.fc1, self.fc2 = TernLinear(d, ffn), TernLinear(ffn, d)
self.ln2 = nn.LayerNorm(d, eps=eps)
def forward(self, x: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
b, t, d = x.shape
q, k, v = (m(x).view(b, t, self.heads, -1).transpose(1, 2) for m in (self.q, self.k, self.v))
attention = F.scaled_dot_product_attention(q, k, v, attn_mask=mask[:, None, None, :])
x = self.ln1(x + self.o(attention.transpose(1, 2).reshape(b, t, d)))
return self.ln2(x + self.fc2(F.gelu(self.fc1(x), approximate=self.gelu)))
class TritEmbed(nn.Module):
def __init__(self, vocab, emb, d, heads, ffn, layers, max_pos, eps, out_dim, loops=1, pool="mean", gelu="tanh"):
super().__init__()
assert emb == d and loops == 1 and pool == "mean", "this release is a single-pass, mean-pooled model"
self.max_pos = max_pos
self.word = Int8Embedding(vocab, emb)
self.pos = Int8Embedding(max_pos, emb)
self.tok_type = nn.Embedding(2, emb)
self.ln_e = nn.LayerNorm(emb, eps=eps)
self.layers = nn.ModuleList(Layer(d, heads, ffn, eps, gelu) for _ in range(layers))
self.out = nn.Linear(d, out_dim, bias=False)
self.tokenizer = None
def embed(self, ids: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
"""Input states: word + position + token-type embeddings, then LayerNorm."""
return self.ln_e(self.word(ids) + self.pos(positions) + self.tok_type.weight[0])
def forward(self, ids: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
x = self.embed(ids, torch.arange(ids.size(1), device=ids.device)[None])
for layer in self.layers:
x = layer(x, mask)
m = mask.unsqueeze(-1).to(x.dtype)
return F.normalize(self.out((x * m).sum(1) / m.sum(1).clamp(min=1)), dim=-1)
def ternary_layers(self):
"""(name, module) for every ternary linear, in encoder order."""
return [(n, m) for n, m in self.named_modules() if isinstance(m, TernLinear)]
@classmethod
def from_pretrained(cls, path) -> "TritEmbed":
from safetensors.torch import load_file
from transformers import AutoTokenizer
path = Path(path)
config = json.loads((path / "config.json").read_text())
if config.get("format") != "trit-packed-v1":
raise ValueError(f"Unknown weight format {config.get('format')!r}")
model = cls(**config["dims"])
state = load_file(path / "model.safetensors")
for name, _ in model.ternary_layers():
state[f"{name}.codes"] = unpack_codes(state[f"{name}.codes"])
model.load_state_dict(state)
for _, m in model.ternary_layers():
m.decode()
model.tokenizer = AutoTokenizer.from_pretrained(path)
return model.eval()
@torch.inference_mode()
def encode(self, texts: list[str], batch: int = 128) -> np.ndarray:
"""Unit-norm float32 vectors; inputs are truncated to 128 tokens. Queries need no prefix.
Runs on the model's device: model.to("cuda") for a GPU."""
device = self.out.weight.device
tokens = self.tokenizer(texts, truncation=True, max_length=self.max_pos)["input_ids"]
order = sorted(range(len(tokens)), key=lambda i: len(tokens[i]))
result = np.empty((len(tokens), self.out.out_features), dtype=np.float32)
for start in range(0, len(order), batch):
indexes = order[start:start + batch]
enc = self.tokenizer.pad({"input_ids": [tokens[i] for i in indexes]}, padding=True, return_tensors="pt")
vectors = self(enc["input_ids"].to(device), enc["attention_mask"].bool().to(device))
result[indexes] = vectors.float().cpu().numpy()
return result