| """Neural network layers for SplitBit LLM — pure NumPy implementation. |
| |
| Layers: |
| - Embedding (token ID → dense vector) |
| - Multi-head self-attention with RoPE |
| - Feed-forward network (MLP with GELU) |
| - Layer normalization (pre-norm) |
| - KV cache for fast autoregressive generation |
| |
| All weights stored as NumPy arrays, quantized via SplitBitQuantizer. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import math |
| from typing import Any |
|
|
| import numpy as np |
|
|
| from .quantization import SplitBitQuantizer |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| def gelu(x: np.ndarray) -> np.ndarray: |
| """GELU activation — Gaussian Error Linear Unit.""" |
| return 0.5 * x * (1.0 + np.tanh(math.sqrt(2.0 / math.pi) * (x + 0.044715 * x ** 3))) |
|
|
|
|
| def softmax(x: np.ndarray, axis: int = -1) -> np.ndarray: |
| """Numerically stable softmax.""" |
| x_max = np.max(x, axis=axis, keepdims=True) |
| exp_x = np.exp(x - x_max) |
| return exp_x / np.sum(exp_x, axis=axis, keepdims=True) |
|
|
|
|
| def layer_norm(x: np.ndarray, gamma: np.ndarray, beta: np.ndarray, eps: float = 1e-5) -> np.ndarray: |
| """Layer normalization.""" |
| mean = np.mean(x, axis=-1, keepdims=True) |
| var = np.var(x, axis=-1, keepdims=True) |
| return gamma * (x - mean) / np.sqrt(var + eps) + beta |
|
|
|
|
| def rope(pos: np.ndarray, d_head: int, base: float = 10000.0) -> tuple[np.ndarray, np.ndarray]: |
| """Rotary Position Embedding (RoPE). |
| |
| Returns cos and sin tensors for rotating Q and K. |
| """ |
| inv_freq = 1.0 / (base ** (np.arange(0, d_head, 2) / d_head)) |
| |
| freqs = np.outer(pos, inv_freq) |
| cos = np.cos(freqs) |
| sin = np.sin(freqs) |
| |
| cos = np.repeat(cos, 2, axis=-1) |
| sin = np.repeat(sin, 2, axis=-1) |
| return cos, sin |
|
|
|
|
| def apply_rope(x: np.ndarray, cos: np.ndarray, sin: np.ndarray) -> np.ndarray: |
| """Apply rotary embedding to tensor x. |
| |
| x: [batch, n_heads, seq_len, d_head] |
| cos/sin: [seq_len, d_head] |
| """ |
| x1 = x[..., 0::2] |
| x2 = x[..., 1::2] |
| |
| cos = cos[None, None, :, :] |
| sin = sin[None, None, :, :] |
| rotated = np.empty_like(x) |
| rotated[..., 0::2] = x1 * cos[..., 0::2] - x2 * sin[..., 0::2] |
| rotated[..., 1::2] = x1 * sin[..., 1::2] + x2 * cos[..., 1::2] |
| return rotated |
|
|
|
|
| class Embedding: |
| """Token embedding layer.""" |
|
|
| def __init__(self, vocab_size: int, d_model: int) -> None: |
| |
| std = math.sqrt(2.0 / (vocab_size + d_model)) |
| self.weight = np.random.randn(vocab_size, d_model).astype(np.float32) * std |
| self.d_model = d_model |
| self.vocab_size = vocab_size |
|
|
| def forward(self, token_ids: np.ndarray) -> np.ndarray: |
| """token_ids: [batch, seq_len] → [batch, seq_len, d_model]""" |
| return self.weight[token_ids] |
|
|
| def backward(self, grad: np.ndarray, token_ids: np.ndarray) -> np.ndarray: |
| """Gradient w.r.t. embedding weights.""" |
| grad_weight = np.zeros_like(self.weight) |
| np.add.at(grad_weight, token_ids, grad) |
| return grad_weight |
|
|
|
|
| class Linear: |
| """Linear layer: y = x @ W^T + b, with SplitBit quantization support.""" |
|
|
| def __init__(self, in_features: int, out_features: int, bias: bool = True) -> None: |
| std = math.sqrt(2.0 / (in_features + out_features)) |
| self.weight = np.random.randn(out_features, in_features).astype(np.float32) * std |
| self.bias = np.zeros(out_features, dtype=np.float32) if bias else None |
| self.in_features = in_features |
| self.out_features = out_features |
| self.use_bias = bias |
| self._quantized = None |
|
|
| def quantize(self, quantizer: SplitBitQuantizer) -> None: |
| """Quantize weights for storage/inference.""" |
| self._quantized = { |
| "weight": quantizer.quantize(self.weight), |
| "bias": self.bias.copy() if self.bias is not None else None, |
| } |
|
|
| def dequantize(self) -> None: |
| """Restore full-precision weights.""" |
| self._quantized = None |
|
|
| def forward(self, x: np.ndarray) -> np.ndarray: |
| """x: [..., in_features] → [..., out_features]""" |
| w = self.weight |
| out = x @ w.T |
| if self.bias is not None: |
| out = out + self.bias |
| return out |
|
|
| def forward_with_cache(self, x: np.ndarray, kv_cache: dict | None = None, layer_idx: int = 0, |
| is_kv: bool = False) -> np.ndarray: |
| """Forward pass that optionally uses/appends to KV cache.""" |
| return self.forward(x) |
|
|
|
|
| class MultiHeadAttention: |
| """Multi-head self-attention with RoPE and KV cache.""" |
|
|
| def __init__(self, d_model: int, n_heads: int, max_seq_len: int = 512) -> None: |
| self.d_model = d_model |
| self.n_heads = n_heads |
| self.d_head = d_model // n_heads |
| self.max_seq_len = max_seq_len |
|
|
| self.wq = Linear(d_model, d_model, bias=False) |
| self.wk = Linear(d_model, d_model, bias=False) |
| self.wv = Linear(d_model, d_model, bias=False) |
| self.wo = Linear(d_model, d_model, bias=False) |
|
|
| |
| pos = np.arange(max_seq_len, dtype=np.float32) |
| self._cos, self._sin = rope(pos, self.d_head) |
|
|
| |
| self._kv_cache: dict[int, tuple[np.ndarray, np.ndarray]] = {} |
|
|
| def forward( |
| self, |
| x: np.ndarray, |
| layer_idx: int = 0, |
| use_cache: bool = False, |
| past_len: int = 0, |
| ) -> np.ndarray: |
| """ |
| x: [batch, seq_len, d_model] |
| Returns: [batch, seq_len, d_model] |
| """ |
| batch, seq_len, _ = x.shape |
|
|
| |
| q = self.wq.forward(x) |
| k = self.wk.forward(x) |
| v = self.wv.forward(x) |
|
|
| |
| q = q.reshape(batch, seq_len, self.n_heads, self.d_head).transpose(0, 2, 1, 3) |
| k = k.reshape(batch, seq_len, self.n_heads, self.d_head).transpose(0, 2, 1, 3) |
| v = v.reshape(batch, seq_len, self.n_heads, self.d_head).transpose(0, 2, 1, 3) |
|
|
| |
| pos_start = past_len |
| pos_end = past_len + seq_len |
| if pos_end > self.max_seq_len: |
| |
| pos = np.arange(pos_end, dtype=np.float32) |
| cos_ext, sin_ext = rope(pos, self.d_head) |
| cos = cos_ext[pos_start:pos_end] |
| sin = sin_ext[pos_start:pos_end] |
| self._cos = cos_ext |
| self._sin = sin_ext |
| else: |
| cos = self._cos[pos_start:pos_end] |
| sin = self._sin[pos_start:pos_end] |
| q = apply_rope(q, cos, sin) |
| k = apply_rope(k, cos, sin) |
|
|
| |
| if use_cache: |
| if layer_idx in self._kv_cache: |
| past_k, past_v = self._kv_cache[layer_idx] |
| k = np.concatenate([past_k, k], axis=2) |
| v = np.concatenate([past_v, v], axis=2) |
| self._kv_cache[layer_idx] = (k, v) |
|
|
| |
| |
| |
| scores = q @ k.transpose(0, 1, 3, 2) / math.sqrt(self.d_head) |
| |
| total_len = k.shape[2] |
| causal = np.triu(np.ones((seq_len, total_len), dtype=bool), k=total_len - seq_len) |
| scores = np.where(causal[None, None, :, :], -1e9, scores) |
| attn = softmax(scores, axis=-1) |
|
|
| |
| out = attn @ v |
| out = out.transpose(0, 2, 1, 3).reshape(batch, seq_len, self.d_model) |
|
|
| return self.wo.forward(out) |
|
|
| def reset_cache(self) -> None: |
| self._kv_cache.clear() |
|
|
|
|
| class FeedForward: |
| """Feed-forward network: 2-layer MLP with GELU.""" |
|
|
| def __init__(self, d_model: int, d_ff: int) -> None: |
| self.w1 = Linear(d_model, d_ff, bias=False) |
| self.w2 = Linear(d_ff, d_model, bias=False) |
|
|
| def forward(self, x: np.ndarray) -> np.ndarray: |
| """x: [..., d_model] → [..., d_model]""" |
| return self.w2.forward(gelu(self.w1.forward(x))) |
|
|
|
|
| class TransformerLayer: |
| """Single transformer layer: pre-norm attention + pre-norm FFN.""" |
|
|
| def __init__(self, d_model: int, n_heads: int, d_ff: int, max_seq_len: int = 512) -> None: |
| self.attn = MultiHeadAttention(d_model, n_heads, max_seq_len) |
| self.ffn = FeedForward(d_model, d_ff) |
|
|
| |
| self.ln1_gamma = np.ones(d_model, dtype=np.float32) |
| self.ln1_beta = np.zeros(d_model, dtype=np.float32) |
| self.ln2_gamma = np.ones(d_model, dtype=np.float32) |
| self.ln2_beta = np.zeros(d_model, dtype=np.float32) |
|
|
| def forward( |
| self, |
| x: np.ndarray, |
| layer_idx: int = 0, |
| use_cache: bool = False, |
| past_len: int = 0, |
| ) -> np.ndarray: |
| """Pre-norm transformer layer.""" |
| |
| normed = layer_norm(x, self.ln1_gamma, self.ln1_beta) |
| attn_out = self.attn.forward(normed, layer_idx=layer_idx, use_cache=use_cache, past_len=past_len) |
| x = x + attn_out |
|
|
| |
| normed = layer_norm(x, self.ln2_gamma, self.ln2_beta) |
| ffn_out = self.ffn.forward(normed) |
| x = x + ffn_out |
|
|
| return x |
|
|
| def get_params(self) -> dict[str, Any]: |
| """Get all parameters as a dict (for saving/quantization).""" |
| return { |
| "wq": self.attn.wq.weight, |
| "wk": self.attn.wk.weight, |
| "wv": self.attn.wv.weight, |
| "wo": self.attn.wo.weight, |
| "w1": self.ffn.w1.weight, |
| "w2": self.ffn.w2.weight, |
| "ln1_gamma": self.ln1_gamma, |
| "ln1_beta": self.ln1_beta, |
| "ln2_gamma": self.ln2_gamma, |
| "ln2_beta": self.ln2_beta, |
| } |
|
|
| def set_params(self, params: dict[str, Any]) -> None: |
| """Set parameters from a dict.""" |
| self.attn.wq.weight = params["wq"] |
| self.attn.wk.weight = params["wk"] |
| self.attn.wv.weight = params["wv"] |
| self.attn.wo.weight = params["wo"] |
| self.ffn.w1.weight = params["w1"] |
| self.ffn.w2.weight = params["w2"] |
| self.ln1_gamma = params["ln1_gamma"] |
| self.ln1_beta = params["ln1_beta"] |
| self.ln2_gamma = params["ln2_gamma"] |
| self.ln2_beta = params["ln2_beta"] |
|
|