Download model.py from MrEngineer/novamind-256m: direct link, hf CLI and curl.
- Browser
- Download file 36.7 kB
-
https://huggingface.co/MrEngineer/novamind-256m/resolve/main/model.py
- Command line
-
hf download hf://MrEngineer/novamind-256m/model.py
-
curl -L -o model.py https://huggingface.co/MrEngineer/novamind-256m/resolve/main/model.py
36.7 kB
| """ | |
| NovaMind-256M: Decoder-Only Conversational Language Model | |
| ========================================================== | |
| Architecture: Decoder-only Transformer | |
| Key design choices: | |
| 1. HiRoPE (Hierarchical RoPE) β adapted from "HiRoPE: Length Extrapolation | |
| for Code Models Using Hierarchical Position" (ACL 2024). | |
| Original paper used it for code structure (token/statement/function hierarchy). | |
| We adapt it for conversation structure: local turn position vs. global dialogue position. | |
| Split head dims into local-base=10K and global-base=500K streams. | |
| 2. Tag-Aware Loss Curriculum β original contribution. | |
| Per-token loss weighting that changes across training phases. | |
| Target: ~252M parameters (after weight tying) | |
| Hardware target: H100 40GB (Lightning.ai student account) | |
| Precision: BF16 | |
| """ | |
| import math | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torch.utils.checkpoint import checkpoint as gradient_checkpoint | |
| from dataclasses import dataclass, field, asdict | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CONFIG | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class NovaMindConfig: | |
| """ | |
| All hyperparameters in one place for clean checkpointing and reproducibility. | |
| Changing a value here changes the entire model β no magic numbers buried | |
| in the code. | |
| """ | |
| # ββ Vocabulary ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vocab_size: int = 32_000 # LLaMA-2 tokenizer (NOT GPT-2's 50K) | |
| # ββ Dimensions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| d_model: int = 1_024 # hidden size | |
| n_heads: int = 16 # number of query attention heads | |
| n_kv_heads: int = 4 # GQA: key/value heads (ratio 4:1) | |
| n_layers: int = 24 # transformer depth | |
| ff_dim: int = 2_304 # SwiGLU inner dim = 2.25 Γ d_model, div by 128 β ~265M total | |
| # ββ Context βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| max_seq_len: int = 2_048 # training context; extendable later via RoPE scaling | |
| # ββ HiRoPE β adapted from HiRoPE (ACL 2024), applied to conversation ββββ | |
| rope_local_base: float = 10_000.0 # local position (within-turn): ~LLaMA default | |
| rope_global_base: float = 500_000.0 # global position (cross-turn): much slower freq | |
| # ββ Regularization ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| dropout: float = 0.0 # no dropout (modern LLM practice) | |
| attn_dropout: float = 0.0 | |
| # ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| init_std: float = 0.02 # base std for embedding and non-residual projections | |
| def head_dim(self) -> int: | |
| assert self.d_model % self.n_heads == 0, "d_model must be divisible by n_heads" | |
| return self.d_model // self.n_heads | |
| def n_rep(self) -> int: | |
| """How many Q heads share each KV head.""" | |
| assert self.n_heads % self.n_kv_heads == 0 | |
| return self.n_heads // self.n_kv_heads | |
| def to_dict(self): | |
| return asdict(self) | |
| def from_dict(cls, d): | |
| return cls(**d) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # RMS NORM | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RMSNorm(nn.Module): | |
| """ | |
| Root Mean Square Layer Normalization (Zhang & Sennrich, 2019). | |
| Faster than LayerNorm because it skips the mean-centering step. | |
| Used by LLaMA, Gemma, Qwen, Mistral β now us. | |
| """ | |
| def __init__(self, dim: int, eps: float = 1e-6): | |
| super().__init__() | |
| self.eps = eps | |
| self.weight = nn.Parameter(torch.ones(dim)) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| # x: [..., dim] | |
| # Compute RMS then scale | |
| rms = x.pow(2).mean(-1, keepdim=True).add(self.eps).rsqrt() | |
| return x * rms * self.weight | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HiRoPE β HIERARCHICAL ROTARY POSITION EMBEDDING | |
| # Adapted from "HiRoPE: Length Extrapolation for Code Models Using Hierarchical | |
| # Position" (ACL 2024). Original: code hierarchy. Our adaptation: dialogue. | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_hier_rope_cache( | |
| seq_len: int, | |
| head_dim: int, | |
| device: torch.device, | |
| local_base: float = 10_000.0, | |
| global_base: float = 500_000.0, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| """ | |
| HiRoPE: Hierarchical Rotary Position Embedding Cache. | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Adapted from: "HiRoPE: Length Extrapolation for Code Models Using | |
| Hierarchical Position" (ACL 2024). | |
| Original paper: used code structure hierarchy (token-in-statement, | |
| statement-in-function) to split RoPE dimensions. | |
| Our adaptation: we use CONVERSATION structure instead. | |
| β’ Dims 0 β¦ head_dim//2-1 β LOCAL RoPE (base=10,000) | |
| Encodes fine-grained position WITHIN a conversation turn. | |
| Same frequency as standard LLaMA RoPE β well-calibrated for | |
| sentence-level syntax and local coreference. | |
| β’ Dims head_dim//2 β¦ head_dim-1 β GLOBAL RoPE (base=500,000) | |
| Encodes coarse position ACROSS conversation turns. | |
| A higher base = slower rotation = can distinguish positions | |
| across much longer spans without frequency wrap-around. | |
| This is what the original HiRoPE paper used for cross-function | |
| dependencies in code; we repurpose it for cross-turn coherence. | |
| Cost: ZERO extra parameters. Purely a change in the frequency basis. | |
| Returns: | |
| cos, sin β each shape [seq_len, head_dim], cached for efficiency | |
| """ | |
| half = head_dim // 2 # split point | |
| # ββ Local stream (dims 0 to half-1) ββββββββββββββββββββββββββββββββββ | |
| # theta_i = 1 / (base^(2i/dim)) for i in [0, half/2) | |
| # We generate half/2 unique theta values, then duplicate for rotation trick | |
| local_theta = 1.0 / ( | |
| local_base ** (torch.arange(0, half, 2, device=device).float() / half) | |
| ) | |
| # ββ Global stream (dims half to head_dim-1) βββββββββββββββββββββββββββ | |
| global_theta = 1.0 / ( | |
| global_base ** (torch.arange(0, half, 2, device=device).float() / half) | |
| ) | |
| positions = torch.arange(seq_len, device=device).float() # [seq_len] | |
| # Outer product: [seq_len, half/2] | |
| local_freqs = torch.outer(positions, local_theta) | |
| global_freqs = torch.outer(positions, global_theta) | |
| # Duplicate for the rotation trick: [q1, q2] β [-q2, q1] | |
| # β shape [seq_len, half] | |
| local_freqs = torch.cat([local_freqs, local_freqs], dim=-1) | |
| global_freqs = torch.cat([global_freqs, global_freqs], dim=-1) | |
| # Concatenate local and global to cover full head_dim: [seq_len, head_dim] | |
| cos = torch.cat([local_freqs.cos(), global_freqs.cos()], dim=-1) | |
| sin = torch.cat([local_freqs.sin(), global_freqs.sin()], dim=-1) | |
| return cos, sin # [seq_len, head_dim] | |
| def _rotate_half(x: torch.Tensor) -> torch.Tensor: | |
| """Standard RoPE rotation: rotate each pair of dims by 90Β°.""" | |
| half = x.shape[-1] // 2 | |
| return torch.cat([-x[..., half:], x[..., :half]], dim=-1) | |
| def apply_hier_rope( | |
| q: torch.Tensor, | |
| k: torch.Tensor, | |
| cos: torch.Tensor, | |
| sin: torch.Tensor, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| """ | |
| Apply HierRoPE to query and key tensors. | |
| Because cos/sin are already split into local//global halves, | |
| this is mathematically identical to standard RoPE β the hierarchy | |
| lives in the frequency basis, not the application code. | |
| Args: | |
| q, k: [batch, n_heads, seq_len, head_dim] | |
| cos, sin: [seq_len, head_dim] (from build_hier_rope_cache) | |
| Returns: | |
| rotated q, k: same shapes as input | |
| """ | |
| # Expand cos/sin to broadcast over batch and head dims | |
| cos = cos[None, None, :, :] # [1, 1, seq_len, head_dim] | |
| sin = sin[None, None, :, :] | |
| q_rot = q * cos + _rotate_half(q) * sin | |
| k_rot = k * cos + _rotate_half(k) * sin | |
| return q_rot, k_rot | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GROUPED QUERY ATTENTION WITH HiRoPE | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class GQACausalAttention(nn.Module): | |
| """ | |
| Grouped Query Attention (GQA) with HiRoPE (adapted from ACL 2024 paper). | |
| GQA uses n_heads query heads but only n_kv_heads key/value heads. | |
| Each KV head is shared by (n_heads // n_kv_heads) query heads. | |
| This reduces KV cache size by 4x during inference β critical for long | |
| conversations. Used by LLaMA 2/3, Mistral, Gemma. | |
| Attention is computed via F.scaled_dot_product_attention which uses | |
| Flash Attention 2 on supported hardware (H100 β ). | |
| """ | |
| def __init__(self, config: NovaMindConfig): | |
| super().__init__() | |
| self.n_heads = config.n_heads | |
| self.n_kv_heads = config.n_kv_heads | |
| self.n_rep = config.n_rep() # Q heads per KV head | |
| self.head_dim = config.head_dim() | |
| self.d_model = config.d_model | |
| # Projections β NO bias (modern LLM best practice, saves params) | |
| self.q_proj = nn.Linear(self.d_model, self.n_heads * self.head_dim, bias=False) | |
| self.k_proj = nn.Linear(self.d_model, self.n_kv_heads * self.head_dim, bias=False) | |
| self.v_proj = nn.Linear(self.d_model, self.n_kv_heads * self.head_dim, bias=False) | |
| self.o_proj = nn.Linear(self.d_model, self.d_model, bias=False) | |
| self.attn_drop = config.attn_dropout | |
| # Cache HiRoPE cos/sin buffers to avoid recomputing every forward pass | |
| # persistent=False β not saved in state_dict (recomputed on load) | |
| self.register_buffer( | |
| "_rope_cos", | |
| torch.zeros(config.max_seq_len, self.head_dim), | |
| persistent=False, | |
| ) | |
| self.register_buffer( | |
| "_rope_sin", | |
| torch.zeros(config.max_seq_len, self.head_dim), | |
| persistent=False, | |
| ) | |
| self._rope_cached_len = 0 | |
| self._rope_local_base = config.rope_local_base | |
| self._rope_global_base = config.rope_global_base | |
| def _get_hier_rope(self, seq_len: int, device: torch.device): | |
| """Lazily compute and cache HiRoPE tables.""" | |
| if seq_len > self._rope_cached_len or self._rope_cos.device != device: | |
| cos, sin = build_hier_rope_cache( | |
| seq_len, self.head_dim, device, | |
| local_base=self._rope_local_base, | |
| global_base=self._rope_global_base, | |
| ) | |
| self._rope_cos = cos | |
| self._rope_sin = sin | |
| self._rope_cached_len = seq_len | |
| return self._rope_cos[:seq_len], self._rope_sin[:seq_len] | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| """ | |
| Args: | |
| x: [B, T, D] β input hidden states | |
| Returns: | |
| out: [B, T, D] | |
| """ | |
| B, T, D = x.shape | |
| # Project to Q, K, V | |
| Q = self.q_proj(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2) | |
| K = self.k_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2) | |
| V = self.v_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2) | |
| # Q: [B, n_heads, T, head_dim] | |
| # K: [B, n_kv_heads, T, head_dim] | |
| # V: [B, n_kv_heads, T, head_dim] | |
| # Apply HiRoPE to Q and K | |
| cos, sin = self._get_hier_rope(T, x.device) | |
| Q, K = apply_hier_rope(Q, K, cos.to(x.dtype), sin.to(x.dtype)) | |
| # Expand KV heads to match Q heads for GQA | |
| # This uses expand() + reshape instead of repeat_interleave β same math, | |
| # but expand() is zero-copy (shares memory), saving VRAM | |
| K = K.unsqueeze(2).expand(B, self.n_kv_heads, self.n_rep, T, self.head_dim)\ | |
| .reshape(B, self.n_heads, T, self.head_dim) | |
| V = V.unsqueeze(2).expand(B, self.n_kv_heads, self.n_rep, T, self.head_dim)\ | |
| .reshape(B, self.n_heads, T, self.head_dim) | |
| # Flash Attention 2 path via PyTorch β causal mask included | |
| out = F.scaled_dot_product_attention( | |
| Q, K, V, | |
| attn_mask=None, | |
| dropout_p=self.attn_drop if self.training else 0.0, | |
| is_causal=True, | |
| ) # [B, n_heads, T, head_dim] | |
| # Reassemble heads and project | |
| out = out.transpose(1, 2).contiguous().view(B, T, D) | |
| return self.o_proj(out) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SWIGLU FEED-FORWARD NETWORK | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SwiGLUFFN(nn.Module): | |
| """ | |
| SwiGLU Feed-Forward Network (Shazeer, 2020; adopted by PaLM, LLaMA, Gemma). | |
| Unlike standard FFN (2 matrices: up-project + down-project), | |
| SwiGLU uses 3 matrices: | |
| gate(x) β passed through SiLU activation (the "gate") | |
| up(x) β element-wise multiplied with the gate output | |
| down(Β·) β project back to d_model | |
| The gating mechanism acts like learned neuron selection β it can | |
| suppress irrelevant features entirely. Result: ~15% better quality | |
| per parameter vs standard GELU FFN (measured in Google's PaLM paper). | |
| """ | |
| def __init__(self, config: NovaMindConfig): | |
| super().__init__() | |
| # All three matrices, no bias | |
| self.gate = nn.Linear(config.d_model, config.ff_dim, bias=False) | |
| self.up = nn.Linear(config.d_model, config.ff_dim, bias=False) | |
| self.down = nn.Linear(config.ff_dim, config.d_model, bias=False) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| # SiLU(gate(x)) β up(x) β element-wise gating | |
| return self.down(F.silu(self.gate(x)) * self.up(x)) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TRANSFORMER BLOCK | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TransformerBlock(nn.Module): | |
| """ | |
| One transformer layer using Pre-RMSNorm architecture: | |
| x β RMSNorm β GQACausalAttention β +x (residual) | |
| β RMSNorm β SwiGLUFFN β +x (residual) | |
| Pre-norm (norm BEFORE the sub-layer) is more training-stable than | |
| Post-norm, especially at 24 layers deep. All modern LLMs use Pre-norm. | |
| """ | |
| def __init__(self, config: NovaMindConfig): | |
| super().__init__() | |
| self.ln_attn = RMSNorm(config.d_model) | |
| self.attn = GQACausalAttention(config) | |
| self.ln_ffn = RMSNorm(config.d_model) | |
| self.ffn = SwiGLUFFN(config) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| # Attention sub-layer with residual connection | |
| x = x + self.attn(self.ln_attn(x)) | |
| # FFN sub-layer with residual connection | |
| x = x + self.ffn(self.ln_ffn(x)) | |
| return x | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TAG-AWARE LOSS β NOVEL CONTRIBUTION #2 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def tag_aware_loss( | |
| logits: torch.Tensor, | |
| targets: torch.Tensor, | |
| token_weights: torch.Tensor | None = None, | |
| ) -> torch.Tensor: | |
| """ | |
| Tag-Aware Loss Curriculum (Novel Contribution #2). | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Standard cross-entropy treats every predicted token equally. | |
| We instead weight each token's loss contribution by its role in the | |
| conversation, and by which training phase we are in: | |
| Token types + phase weights: | |
| ββββββββββββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββββββ | |
| β Token type β Phase 1 β Phase 2 β Phase 3 β | |
| β β Pretrain β SFT β CoT SFT β | |
| ββββββββββββββββββΌβββββββββββΌβββββββββββΌβββββββββββββββ€ | |
| β <think> β 0.0 β 0.5 β 1.5 β BOOSTβ | |
| β <assistant> β 1.0 β 1.0 β 1.0 β | |
| β <human>/system β 0.0 β 0.0 β 0.0 β | |
| ββββββββββββββββββ΄βββββββββββ΄βββββββββββ΄βββββββββββββββ | |
| Why this is novel: | |
| - Most papers use binary masking (weight is 0 or 1) | |
| - A curriculum that changes weights across phases has not been | |
| published at this scale | |
| - Phase 3 boosting (weight=1.5 for think tokens) forces the model to | |
| "care" about the quality of its own reasoning chain | |
| Args: | |
| logits: [B, T, V] β raw output logits | |
| targets: [B, T] β target token ids (-1 = ignore this position) | |
| token_weights: [B, T] β per-token float weight (None β uniform 1.0) | |
| Returns: | |
| Scalar loss value | |
| """ | |
| B, T, V = logits.shape | |
| # Flat views for loss computation | |
| logits_flat = logits.view(B * T, V) | |
| targets_flat = targets.view(B * T) | |
| if token_weights is None: | |
| # Standard cross-entropy when no curriculum weights provided | |
| return F.cross_entropy(logits_flat, targets_flat, ignore_index=-1) | |
| # Compute per-token loss (reduction='none' gives us loss per position) | |
| per_token_loss = F.cross_entropy( | |
| logits_flat, targets_flat, | |
| ignore_index=-1, | |
| reduction="none", | |
| ) # [B*T] | |
| weights_flat = token_weights.view(B * T) | |
| # Mask out ignored positions (targets == -1) | |
| # F.cross_entropy with ignore_index already zeroes them, but | |
| # we also zero their weights to avoid dividing by them | |
| valid_mask = (targets_flat != -1).float() | |
| weights_flat = weights_flat * valid_mask | |
| # Weighted mean: sum(w_i * loss_i) / sum(w_i) | |
| # Small epsilon to prevent div-by-zero on pathological batches | |
| weighted_loss = (per_token_loss * weights_flat).sum() | |
| weight_sum = weights_flat.sum().clamp(min=1e-8) | |
| return weighted_loss / weight_sum | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # NOVAMIND-256M β MAIN MODEL | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class NovaMind256M(nn.Module): | |
| """ | |
| NovaMind-256M: 256M parameter conversational LLM. | |
| Architectural features: | |
| - Decoder-only transformer (autoregressive) | |
| - Pre-RMSNorm for training stability | |
| - GQA (16Q / 4KV) for inference efficiency | |
| - SwiGLU FFN for parameter efficiency | |
| - HiRoPE for hierarchical positional encoding (adapted from ACL 2024 paper) | |
| - Weight-tied embedding and LM head (saves 33M params) | |
| - Tag-Aware Loss Curriculum [NOVEL] | |
| - BF16 training, Flash Attention 2 | |
| """ | |
| def __init__(self, config: NovaMindConfig): | |
| super().__init__() | |
| self.config = config | |
| self._use_gradient_checkpointing = False | |
| # ββ Embedding ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| self.token_emb = nn.Embedding(config.vocab_size, config.d_model) | |
| self.emb_dropout = nn.Dropout(config.dropout) if config.dropout > 0 else nn.Identity() | |
| # ββ Transformer Stack βββββββββββββββββββββββββββββββββββββββββββββ | |
| self.blocks = nn.ModuleList([ | |
| TransformerBlock(config) for _ in range(config.n_layers) | |
| ]) | |
| # ββ Output head ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| self.ln_f = RMSNorm(config.d_model) | |
| self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False) | |
| # Weight tying: lm_head and token_emb share the same weight matrix. | |
| # This saves vocab_size Γ d_model = 32,000 Γ 1,024 = 32.77M parameters. | |
| # The embedding learns "what does this token mean?" and the LM head | |
| # learns "how does the hidden state score as this token?" β they're | |
| # two views of the same embedding space. | |
| self.lm_head.weight = self.token_emb.weight | |
| # ββ Weight Initialization ββββββββββββββββββββββββββββββββββββββββ | |
| self.apply(self._init_weights) | |
| def _init_weights(self, module: nn.Module): | |
| """ | |
| GPT-2 style initialization with scaled residual projections. | |
| Output projections (o_proj, down in FFN) are scaled by 1/sqrt(2*N) | |
| where N = number of layers. This prevents the residual stream from | |
| growing proportionally to depth, which would destabilize training. | |
| Reference: GPT-2 paper, Table 2, "Modified initialization" footnote. | |
| """ | |
| std = self.config.init_std | |
| if isinstance(module, nn.Linear): | |
| nn.init.normal_(module.weight, mean=0.0, std=std) | |
| if module.bias is not None: | |
| nn.init.zeros_(module.bias) | |
| elif isinstance(module, nn.Embedding): | |
| nn.init.normal_(module.weight, mean=0.0, std=std) | |
| # Scale residual output projections (o_proj and FFN down) | |
| # Identify them by name β they project *into* the residual stream | |
| residual_proj_names = {"o_proj", "down"} | |
| for name, param in module.named_parameters(recurse=False): | |
| # Use the parent module name to identify projection type | |
| parent_name = type(module).__name__ | |
| if any(p in parent_name.lower() or p in name.lower() | |
| for p in residual_proj_names): | |
| nn.init.normal_( | |
| param, | |
| mean=0.0, | |
| std=std / math.sqrt(2 * self.config.n_layers), | |
| ) | |
| # ββ Gradient Checkpointing βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def gradient_checkpointing_enable(self): | |
| """ | |
| Enable gradient checkpointing to trade compute for VRAM. | |
| Discards intermediate activations during forward pass, recomputes | |
| them during backward. Saves ~40% VRAM at cost of ~20% slower training. | |
| CRITICAL for fitting 256M model + optimizer states on H100 40GB. | |
| """ | |
| self._use_gradient_checkpointing = True | |
| def gradient_checkpointing_disable(self): | |
| self._use_gradient_checkpointing = False | |
| # ββ Forward Pass ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def forward( | |
| self, | |
| input_ids: torch.Tensor, | |
| targets: torch.Tensor | None = None, | |
| token_weights: torch.Tensor | None = None, | |
| ) -> tuple[torch.Tensor, torch.Tensor | None]: | |
| """ | |
| Args: | |
| input_ids: [B, T] β token ids | |
| targets: [B, T] β target ids for loss computation (-1 = ignore) | |
| If None, loss is not computed (inference mode) | |
| token_weights: [B, T] β per-token float weights for Tag-Aware Loss | |
| If None, uses standard uniform cross-entropy | |
| Returns: | |
| (logits, loss) | |
| - logits: [B, T, V] during inference; empty tensor during training | |
| (returning full logits during training on DataParallel is wasteful β | |
| it forces a massive tensor transfer to GPU 0. We only need the loss.) | |
| - loss: scalar tensor if targets provided, else None | |
| """ | |
| B, T = input_ids.shape | |
| assert T <= self.config.max_seq_len, ( | |
| f"Input length {T} exceeds max_seq_len {self.config.max_seq_len}" | |
| ) | |
| # Embed tokens | |
| x = self.emb_dropout(self.token_emb(input_ids)) # [B, T, D] | |
| # Pass through transformer blocks | |
| for block in self.blocks: | |
| if self._use_gradient_checkpointing and self.training: | |
| # gradient_checkpoint recomputes this block's activations | |
| # during backward instead of storing them β saves VRAM | |
| x = gradient_checkpoint(block, x, use_reentrant=False) | |
| else: | |
| x = block(x) | |
| # Final norm + project to vocabulary | |
| logits = self.lm_head(self.ln_f(x)) # [B, T, V] | |
| loss = None | |
| if targets is not None: | |
| loss = tag_aware_loss(logits, targets, token_weights) | |
| # Drop logits during training to avoid PCIe transfer on multi-GPU | |
| if self.training: | |
| logits = torch.empty(0, device=logits.device) | |
| return logits, loss | |
| # ββ Generation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate( | |
| self, | |
| input_ids: torch.Tensor, | |
| max_new_tokens: int = 256, | |
| temperature: float = 0.7, | |
| top_k: int = 50, | |
| top_p: float = 0.9, | |
| repetition_penalty: float = 1.2, | |
| stop_token_ids: list[int] | None = None, | |
| ) -> torch.Tensor: | |
| """ | |
| Autoregressive text generation with temperature, top-k, top-p sampling | |
| and repetition penalty. | |
| Args: | |
| input_ids: [1, T] β prompt tokens | |
| max_new_tokens: maximum tokens to generate | |
| temperature: sampling temperature (lower = more focused) | |
| top_k: keep only top-k logits before sampling | |
| top_p: nucleus sampling threshold | |
| repetition_penalty: >1.0 penalizes repeated tokens | |
| stop_token_ids: list of token ids that stop generation | |
| Returns: | |
| token ids including the prompt: [1, T + generated] | |
| """ | |
| self.eval() | |
| for _ in range(max_new_tokens): | |
| # Truncate context to max_seq_len (sliding window) | |
| idx_cond = input_ids[:, -self.config.max_seq_len:] | |
| logits, _ = self(idx_cond) # [1, T, V] | |
| logits = logits[:, -1, :] # [1, V] β last position only | |
| # Repetition penalty: down-weight tokens already in context | |
| if repetition_penalty != 1.0: | |
| for token_id in set(idx_cond[0].tolist()): | |
| if logits[0, token_id] < 0: | |
| logits[0, token_id] *= repetition_penalty | |
| else: | |
| logits[0, token_id] /= repetition_penalty | |
| # Temperature scaling | |
| logits = logits / max(temperature, 1e-8) | |
| # Top-k filtering: zero out all but top-k logits | |
| if top_k is not None and top_k > 0: | |
| top_k_val = min(top_k, logits.size(-1)) | |
| kth_val = torch.topk(logits, top_k_val).values[:, -1, None] | |
| logits[logits < kth_val] = float("-inf") | |
| # Top-p (nucleus) filtering: keep smallest set of tokens | |
| # whose cumulative probability exceeds top_p | |
| if top_p is not None and 0.0 < top_p < 1.0: | |
| sorted_logits, sorted_idx = torch.sort(logits, dim=-1, descending=True) | |
| cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1) | |
| # Remove tokens with cumulative prob above threshold | |
| # (shift by 1 to keep the token that *crosses* the threshold) | |
| sorted_remove = cumulative_probs - F.softmax(sorted_logits, dim=-1) > top_p | |
| sorted_logits[sorted_remove] = float("-inf") | |
| logits = torch.zeros_like(logits).scatter_( | |
| dim=-1, index=sorted_idx, src=sorted_logits | |
| ) | |
| probs = F.softmax(logits, dim=-1) | |
| next_id = torch.multinomial(probs, num_samples=1) # [1, 1] | |
| input_ids = torch.cat([input_ids, next_id], dim=1) | |
| # Stop if we hit a stop token | |
| if stop_token_ids and next_id.item() in stop_token_ids: | |
| break | |
| return input_ids | |
| # ββ Parameter Counting ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def count_parameters(self, print_table: bool = True) -> int: | |
| """ | |
| Count and optionally print a detailed parameter breakdown. | |
| Useful for verifying we hit ~252M. | |
| """ | |
| cfg = self.config | |
| embed_params = cfg.vocab_size * cfg.d_model | |
| hd = cfg.head_dim() | |
| # Per-layer: Q+K+V+O projections + gate+up+down FFN + 2ΓRMSNorm | |
| # QKV projections: (n_q + n_kv + n_kv) Γ head_dim Γ d_model | |
| qkv_params = (cfg.n_heads + 2 * cfg.n_kv_heads) * hd * cfg.d_model | |
| # Output projection: d_model Γ d_model | |
| o_params = cfg.d_model * cfg.d_model | |
| attn_params = qkv_params + o_params | |
| # SwiGLU: gate + up + down (3 matrices) | |
| ffn_params = 3 * cfg.d_model * cfg.ff_dim | |
| norm_params = 2 * cfg.d_model # two RMSNorm per block | |
| per_layer = attn_params + ffn_params + norm_params | |
| final_norm = cfg.d_model | |
| total_with_tie = embed_params + cfg.n_layers * per_layer + final_norm | |
| total_trainable = sum(p.numel() for p in self.parameters() if p.requires_grad) | |
| if print_table: | |
| w = 46 | |
| print("β" * w) | |
| print(f" NovaMind-256M Parameter Breakdown") | |
| print("β" * w) | |
| print(f" {'Token Embedding (shared w/ lm_head)':<40} {embed_params/1e6:>6.2f}M") | |
| print(f" {'Attention QKV projections (per layer)':<40} {qkv_params/1e6:>6.2f}M") | |
| print(f" {'Attention O projection (per layer)':<40} {o_params/1e6:>6.2f}M") | |
| print(f" {'SwiGLU FFN (per layer)':<40} {ffn_params/1e6:>6.2f}M") | |
| print(f" {'RMSNorm Γ 2 (per layer)':<40} {norm_params/1e6:>6.4f}M") | |
| print(f" {'Γ {n} layers total':<40} {cfg.n_layers * per_layer/1e6:>6.2f}M") | |
| print(f" {'Final RMSNorm':<40} {final_norm/1e6:>6.4f}M") | |
| print(f" {'LM Head (weight-tied to embedding)':<40} {'β':>4} 0.00M") | |
| print("β" * w) | |
| print(f" {'TOTAL (formula estimate)':<40} {total_with_tie/1e6:>6.2f}M") | |
| print(f" {'TOTAL (actual PyTorch count)':<40} {total_trainable/1e6:>6.2f}M") | |
| print("β" * w) | |
| return total_trainable | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # QUICK SANITY CHECK | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| import sys | |
| config = NovaMindConfig() | |
| print(f"\nBuilding NovaMind-256M with config:") | |
| for k, v in config.to_dict().items(): | |
| print(f" {k}: {v}") | |
| print() | |
| model = NovaMind256M(config) | |
| n_params = model.count_parameters(print_table=True) | |
| # Verify target range | |
| target_min, target_max = 245e6, 265e6 | |
| status = "β " if target_min <= n_params <= target_max else "β" | |
| print(f"\n{status} Parameter count: {n_params/1e6:.2f}M (target: 245β265M)\n") | |
| if not (target_min <= n_params <= target_max): | |
| sys.exit(1) | |
| # Quick forward pass test | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| model = model.to(device) | |
| model.gradient_checkpointing_enable() | |
| B, T = 2, 128 | |
| ids = torch.randint(0, config.vocab_size, (B, T), device=device) | |
| targets = ids.clone() | |
| weights = torch.ones(B, T, device=device) | |
| weights[:, :10] = 0.0 # mask first 10 tokens (simulate user tokens) | |
| weights[:, 20:40] = 1.5 # boost tokens 20-40 (simulate think tokens in Phase 3) | |
| with torch.autocast(device_type=device.type, dtype=torch.bfloat16): | |
| logits, loss = model(ids, targets, weights) | |
| print(f"Forward pass OK β loss: {loss.item():.4f}") | |
| print(f"Logits shape during training: {logits.shape} (should be empty [0])") | |
| # Test generation | |
| prompt = torch.randint(0, config.vocab_size, (1, 10), device=device) | |
| with torch.autocast(device_type=device.type, dtype=torch.bfloat16): | |
| generated = model.generate(prompt, max_new_tokens=20, temperature=0.8) | |
| print(f"Generation OK β output shape: {generated.shape}") | |
| print("\nβ All checks passed. model.py is ready.\n") | |