Omarrran's picture
Add Transformers-compatible ks_byte_lm SpaceByte release
45121cf verified
Raw
History Blame Contribute Delete
14.2 kB
"""Byte-level decoder language models.
`variant="plain"` is the P1 small Llama-style causal decoder over raw UTF-8
bytes. `variant="spacebyte"` is the P2 word-boundary hierarchy: cheap local
byte blocks run everywhere, expensive global blocks run only on patch-boundary
positions, then the latest patch context is scattered back to every byte before
optional local output blocks.
forward() returns (logits, loss, parts) where `parts` breaks the loss into the
cross-entropy term and the z-loss term for logging.
"""
from __future__ import annotations
import math
from typing import Dict, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from .config import BOS_ID, EOS_ID, VOCAB_SIZE, ByteLMConfig
from .layers import Block, RMSNorm, RotaryEmbedding, build_doc_attn_mask
# ASCII whitespace bytes that delimit Kashmiri words in the normalized corpus.
# UTF-8 non-ASCII punctuation is deliberately not treated as a patch boundary.
_SPACEBYTE_WHITESPACE = (9, 10, 11, 12, 13, 32) # \t \n \v \f \r space
def build_spacebyte_boundary_mask(
x: torch.Tensor,
seg_ids: Optional[torch.Tensor] = None,
spacelike_bytes: Tuple[int, ...] = _SPACEBYTE_WHITESPACE,
) -> torch.Tensor:
"""Return [B,T] positions promoted to SpaceByte global patches.
A text patch starts at the first byte of each document/window and at a
spacelike byte that is not itself preceded by another spacelike byte. BOS/EOS
ids are also promoted because packed byte streams use them as hard document
boundaries. Marking only the first byte in a run of spaces prevents wasting
global compute on repeated whitespace.
"""
if x.dim() != 2:
raise ValueError(f"x must be [B,T], got shape {tuple(x.shape)}")
B, T = x.shape
is_space = torch.zeros_like(x, dtype=torch.bool)
for byte in spacelike_bytes:
is_space |= x == byte
prev_space = torch.zeros_like(is_space)
prev_space[:, 1:] = is_space[:, :-1]
boundary = is_space & ~prev_space
boundary |= (x == BOS_ID) | (x == EOS_ID)
# Every sampled window needs at least one patch. When segment ids are known,
# also seed each new document segment so document-aware global attention has
# a patch before the first visible space.
boundary[:, 0] = True
if seg_ids is not None:
new_doc = torch.zeros_like(boundary)
new_doc[:, 1:] = seg_ids[:, 1:] != seg_ids[:, :-1]
boundary |= new_doc
return boundary
class ByteDecoder(nn.Module):
def __init__(self, cfg: ByteLMConfig):
super().__init__()
cfg.validate()
self.cfg = cfg
self.embed = nn.Embedding(VOCAB_SIZE, cfg.d_model)
self.drop = nn.Dropout(cfg.dropout)
self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layers)])
self.norm_f = RMSNorm(cfg.d_model)
self.lm_head = nn.Linear(cfg.d_model, VOCAB_SIZE, bias=False)
self.rope = RotaryEmbedding(cfg.head_dim, cfg.rope_theta)
if cfg.tie_embeddings:
self.lm_head.weight = self.embed.weight
self.apply(self._init_weights)
# Scale residual-path projections by 1/sqrt(2*n_layers) for stable depth.
scale = 1.0 / math.sqrt(2 * cfg.n_layers)
for name, p in self.named_parameters():
if name.endswith("wo.weight") or name.endswith("w_down.weight"):
with torch.no_grad():
p.mul_(scale)
@staticmethod
def _init_weights(m: nn.Module):
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, mean=0.0, std=0.02)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Embedding):
nn.init.normal_(m.weight, mean=0.0, std=0.02)
def num_params(self, non_embedding: bool = False) -> int:
n = sum(p.numel() for p in self.parameters())
if non_embedding and not self.cfg.tie_embeddings:
n -= self.embed.weight.numel()
return n
def forward(
self,
x: torch.Tensor,
y: Optional[torch.Tensor] = None,
seg_ids: Optional[torch.Tensor] = None,
pos_ids: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, Optional[torch.Tensor], Dict[str, float]]:
B, T = x.shape
if pos_ids is None:
pos_ids = torch.arange(T, device=x.device).unsqueeze(0).expand(B, T)
cos, sin = self.rope(pos_ids)
attn_mask = build_doc_attn_mask(seg_ids) if seg_ids is not None else None
h = self.drop(self.embed(x))
for blk in self.blocks:
h = blk(h, cos, sin, attn_mask)
h = self.norm_f(h)
logits = self.lm_head(h)
loss, parts = _loss_parts(logits, y, self.cfg)
return logits, loss, parts
@torch.no_grad()
def generate(self, idx: torch.Tensor, max_new_tokens: int,
temperature: float = 1.0, top_k: Optional[int] = None,
eos_id: Optional[int] = None) -> torch.Tensor:
"""Autoregressive sampling (no doc-mask; single growing sequence)."""
self.eval()
for _ in range(max_new_tokens):
idx_cond = idx[:, -self.cfg.ctx_len:]
logits, _, _ = self(idx_cond)
logits = logits[:, -1, :] / max(temperature, 1e-6)
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = float("-inf")
probs = F.softmax(logits, dim=-1)
nxt = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, nxt), dim=1)
if eos_id is not None and (nxt == eos_id).all():
break
return idx
class SpaceByteDecoder(nn.Module):
"""SpaceByte-style hierarchy over raw bytes.
Local-in blocks process every byte cheaply. Global blocks process only patch
boundary states (word/document boundaries), with causal/document masks over
the patch sequence. The latest available global patch state is then scattered
back to each byte and added as context before local-out blocks and the shared
LM head. Setting n_local_in=n_local_out=0 and n_global=n_layers makes this
reduce exactly to `ByteDecoder` when every position is a boundary.
"""
def __init__(self, cfg: ByteLMConfig):
super().__init__()
cfg.validate()
self.cfg = cfg
self.embed = nn.Embedding(VOCAB_SIZE, cfg.d_model)
self.drop = nn.Dropout(cfg.dropout)
self.local_in_blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_local_in)])
self.global_blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_global)])
self.local_out_blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_local_out)])
self.norm_f = RMSNorm(cfg.d_model)
self.lm_head = nn.Linear(cfg.d_model, VOCAB_SIZE, bias=False)
self.rope = RotaryEmbedding(cfg.head_dim, cfg.rope_theta)
if cfg.tie_embeddings:
self.lm_head.weight = self.embed.weight
self.apply(ByteDecoder._init_weights)
total_layers = max(1, cfg.n_local_in + cfg.n_global + cfg.n_local_out)
scale = 1.0 / math.sqrt(2 * total_layers)
for name, p in self.named_parameters():
if name.endswith("wo.weight") or name.endswith("w_down.weight"):
with torch.no_grad():
p.mul_(scale)
def num_params(self, non_embedding: bool = False) -> int:
n = sum(p.numel() for p in self.parameters())
if non_embedding and not self.cfg.tie_embeddings:
n -= self.embed.weight.numel()
return n
@staticmethod
def gather_patches(
h: torch.Tensor,
boundary_mask: torch.Tensor,
pos_ids: torch.Tensor,
seg_ids: Optional[torch.Tensor],
max_patches: int,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""Gather boundary states into a padded [B,P,D] patch sequence.
Returns `(patch_h, patch_valid, patch_pos, patch_seg, patch_ids)`, where
`patch_ids[b,t]` is the index of the latest patch at or before byte `t`.
If a sequence has more than `max_patches` boundaries, later bytes reuse
the last retained patch context; this keeps memory bounded and explicit.
"""
if h.dim() != 3 or boundary_mask.dim() != 2:
raise ValueError("h must be [B,T,D] and boundary_mask must be [B,T]")
B, T, D = h.shape
counts = boundary_mask.sum(dim=1).clamp_min(1)
P = int(min(max_patches, counts.max().item()))
device = h.device
gather_idx = torch.zeros((B, P), dtype=torch.long, device=device)
patch_valid = torch.zeros((B, P), dtype=torch.bool, device=device)
patch_ids = torch.empty((B, T), dtype=torch.long, device=device)
for b in range(B):
idx = torch.nonzero(boundary_mask[b], as_tuple=False).flatten()
if idx.numel() == 0:
idx = torch.zeros(1, dtype=torch.long, device=device)
keep = idx[:P]
n = keep.numel()
gather_idx[b, :n] = keep
patch_valid[b, :n] = True
ordinal = torch.cumsum(boundary_mask[b].to(torch.long), dim=0) - 1
patch_ids[b] = ordinal.clamp(min=0, max=max(n - 1, 0))
patch_h = h.gather(1, gather_idx.unsqueeze(-1).expand(B, P, D))
patch_pos = pos_ids.gather(1, gather_idx)
if seg_ids is None:
patch_seg = torch.zeros((B, P), dtype=torch.long, device=device)
else:
patch_seg = seg_ids.gather(1, gather_idx)
return patch_h, patch_valid, patch_pos, patch_seg, patch_ids
@staticmethod
def scatter_patches(patch_h: torch.Tensor, patch_ids: torch.Tensor) -> torch.Tensor:
"""Scatter latest patch states from [B,P,D] back to each byte [B,T,D]."""
B, T = patch_ids.shape
D = patch_h.size(-1)
return patch_h.gather(1, patch_ids.unsqueeze(-1).expand(B, T, D))
@staticmethod
def _patch_attn_mask(patch_seg: torch.Tensor, patch_valid: torch.Tensor) -> torch.Tensor:
"""Causal/document patch mask that keeps padded query rows finite."""
mask = build_doc_attn_mask(patch_seg)
valid = patch_valid.unsqueeze(1)
mask = mask & valid.unsqueeze(-1) & valid.unsqueeze(-2)
eye = torch.eye(patch_seg.size(1), dtype=torch.bool, device=patch_seg.device)
# Padded rows are ignored later, but SDPA still needs at least one True.
mask = mask | (eye.unsqueeze(0).unsqueeze(0) & ~valid.unsqueeze(-1))
return mask
def forward(
self,
x: torch.Tensor,
y: Optional[torch.Tensor] = None,
seg_ids: Optional[torch.Tensor] = None,
pos_ids: Optional[torch.Tensor] = None,
boundary_mask: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, Optional[torch.Tensor], Dict[str, float]]:
B, T = x.shape
if pos_ids is None:
pos_ids = torch.arange(T, device=x.device).unsqueeze(0).expand(B, T)
byte_cos, byte_sin = self.rope(pos_ids)
byte_attn_mask = build_doc_attn_mask(seg_ids) if seg_ids is not None else None
h = self.drop(self.embed(x))
for blk in self.local_in_blocks:
h = blk(h, byte_cos, byte_sin, byte_attn_mask)
if boundary_mask is None:
boundary_mask = build_spacebyte_boundary_mask(x, seg_ids=seg_ids)
patch_h, patch_valid, patch_pos, patch_seg, patch_ids = self.gather_patches(
h, boundary_mask, pos_ids, seg_ids, self.cfg.max_patches
)
patch_cos, patch_sin = self.rope(patch_pos)
patch_attn_mask = self._patch_attn_mask(patch_seg, patch_valid)
for blk in self.global_blocks:
patch_h = blk(patch_h, patch_cos, patch_sin, patch_attn_mask)
patch_context = self.scatter_patches(patch_h, patch_ids)
if self.cfg.n_local_in == 0 and self.cfg.n_local_out == 0:
# Degenerate hierarchy: the global stream is the whole residual
# stream, so all-boundary inputs exactly match the plain decoder.
h = patch_context
else:
h = h + patch_context
for blk in self.local_out_blocks:
h = blk(h, byte_cos, byte_sin, byte_attn_mask)
h = self.norm_f(h)
logits = self.lm_head(h)
loss, parts = _loss_parts(logits, y, self.cfg)
return logits, loss, parts
@torch.no_grad()
def generate(self, idx: torch.Tensor, max_new_tokens: int,
temperature: float = 1.0, top_k: Optional[int] = None,
eos_id: Optional[int] = None) -> torch.Tensor:
"""Autoregressive sampling with SpaceByte boundary detection per window."""
self.eval()
for _ in range(max_new_tokens):
idx_cond = idx[:, -self.cfg.ctx_len:]
logits, _, _ = self(idx_cond)
logits = logits[:, -1, :] / max(temperature, 1e-6)
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = float("-inf")
probs = F.softmax(logits, dim=-1)
nxt = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, nxt), dim=1)
if eos_id is not None and (nxt == eos_id).all():
break
return idx
def _loss_parts(
logits: torch.Tensor,
y: Optional[torch.Tensor],
cfg: ByteLMConfig,
) -> Tuple[Optional[torch.Tensor], Dict[str, float]]:
"""Shared CE + z-loss computation for both decoder variants."""
if y is None:
return None, {}
ce = F.cross_entropy(
logits.view(-1, VOCAB_SIZE), y.reshape(-1),
label_smoothing=cfg.label_smoothing,
)
z = logits.logsumexp(dim=-1).pow(2).mean()
loss = ce + cfg.z_loss_weight * z
return loss, {"ce": ce.item(), "z": z.item(), "loss": loss.item()}
def build_model(cfg: ByteLMConfig) -> nn.Module:
if cfg.variant == "plain":
return ByteDecoder(cfg)
if cfg.variant == "spacebyte":
return SpaceByteDecoder(cfg)
raise ValueError(f"unknown variant {cfg.variant!r}")