""" Morphable Hybrid Attention — layer-level linearization with learnable selection, combined with TriAttention token-level KV compression on the surviving full layers. Implements the four-stage pipeline from the "Morphable Layer" figure: (1) Morphable Layer Construction Every attention layer gets a frozen Full-Attention path A_full (the pretrained MLADerfXSAAttention) and a trainable Linear-Attention sibling A_lin that reuses the frozen Q/K/V/O projections but replaces softmax with a learnable positive feature map (LoLCATs-style). A_lin is trained to match A_full per layer: L_hidden = (1/L) Σ_l || H_lin^(l) - H_full^(l) ||² (2) Layer Selection via Joint Opt. + Linearization Reg. Each layer mixes the two paths through a learnable gate α^(l) ∈ (0,1): H_mix^(l) = α^(l) · H_full^(l) + (1 - α^(l)) · H_lin^(l) trained on synthetic passkey-retrieval data with L_total = L_align + λ L_reg = (1/(L|T|)) Σ_l Σ_t || H_mix,t^(l) - H_full,t^(l) ||² + λ Σ_l α^(l) The α-penalty pushes layers toward linear unless full attention is genuinely needed (retrieval heads), so α ranks layer importance. (3) Discretize Hybrid Layers Keep the top-k layers by α as Full Attention; linearize the rest. (4) Distillation & Finetuning Logits KL distillation (student hybrid vs. frozen teacher) + long-context FT. Combined with triattention.py: the discretized "full" layers still hold a KV cache, so TriAttention prunes them to a token budget; the "linear" layers carry an O(1) recurrent state instead. See model.py generate_hybrid(). """ import math import torch import torch.nn as nn import torch.nn.functional as F # --------------------------------------------------------------------------- # # trainable linear attention (reuses the frozen full-attention projections) # --------------------------------------------------------------------------- # def _feature_map(x, temp): """Positive feature map φ(x) = elu(x·temp) + 1 (LoLCATs-style, learnable temp).""" return F.elu(x * temp) + 1.0 class LinearAttention(nn.Module): """Linear-attention twin of MLADerfXSAAttention. Shares (by reference) the frozen full-attention Q/K/V/O projections, QK-norm and RoPE, and only learns a small per-head feature-map temperature. Softmax(qkᵀ) is replaced by the kernel weight φ(q)·φ(k), giving causal linear attention with an O(d²) recurrent state for decoding: out_t = Σ_{s≤t} (φ(q_t)·φ(k_s)) v_s / Σ_{s≤t} (φ(q_t)·φ(k_s)) """ def __init__(self, full_attn, cfg): super().__init__() self.full = full_attn # frozen; used only for its weights self.num_heads = cfg.n_head self.num_kv_heads = cfg.num_key_value_heads self.head_dim = cfg.head_dim self.nope_head_dim = cfg.nope_head_dim self.kv_groups = self.num_heads // self.num_kv_heads self.use_qk_norm = cfg.use_qk_norm # learnable per-head feature-map temperatures (init 1 -> φ = elu+1) self.q_temp = nn.Parameter(torch.ones(self.num_heads, 1)) self.k_temp = nn.Parameter(torch.ones(self.num_kv_heads, 1)) def _project(self, x, position_ids): """Reuse the frozen full path's projections + RoPE to get post-RoPE q,k,v.""" f = self.full B, S, _ = x.shape q = f.q_b_proj(f.q_a_norm(f.q_a_proj(x))) q = q.view(B, S, self.num_heads, self.head_dim).transpose(1, 2) k = f.k_proj(x).view(B, S, self.num_kv_heads, self.head_dim).transpose(1, 2) v = f.v_proj(x).view(B, S, self.num_kv_heads, self.head_dim).transpose(1, 2) if self.use_qk_norm: q, k = f.q_norm(q), f.k_norm(k) d = self.nope_head_dim q = torch.cat([q[..., :d], f.rope(q[..., d:], position_ids)], dim=-1) k = torch.cat([k[..., :d], f.rope(k[..., d:], position_ids)], dim=-1) return q, k, v def _out(self, y): f = self.full B, H, S, D = y.shape y = y.transpose(1, 2).contiguous().view(B, S, H * D) return f.o_b_proj(f.o_a_proj(y)) def forward(self, x, position_ids, past_kv=None, use_cache=False): f = self.full B, S, _ = x.shape q, k, v = self._project(x, position_ids) # expand kv heads to query heads (GQA) if self.kv_groups > 1: k = k.repeat_interleave(self.kv_groups, dim=1) v = v.repeat_interleave(self.kv_groups, dim=1) qt = self.q_temp.repeat_interleave(1, 0).view(1, self.num_heads, 1, 1) kt = self.k_temp.repeat_interleave(self.kv_groups, 0).view(1, self.num_heads, 1, 1) phi_q = _feature_map(q, qt) # [B,H,S,D] phi_k = _feature_map(k, kt) # recurrent decoding: carry state (KV = Σ φk⊗v, Z = Σ φk) if use_cache and past_kv is not None: state_kv, state_z = past_kv[1], past_kv[2] # ('linear', KV, Z) # accumulate this step's tokens into the state, then read out causally outs = [] for t in range(S): pk, vv = phi_k[:, :, t], v[:, :, t] # [B,H,D] state_kv = state_kv + pk.unsqueeze(-1) * vv.unsqueeze(-2) # [B,H,D,D] state_z = state_z + pk # [B,H,D] pq = phi_q[:, :, t] num = (pq.unsqueeze(-2) @ state_kv).squeeze(-2) # [B,H,D] den = (pq * state_z).sum(-1, keepdim=True).clamp_min(1e-6) outs.append(num / den) y = torch.stack(outs, dim=2) # [B,H,S,D] present = ("linear", state_kv, state_z) return self._out(y), present # parallel form (exact same result), O(S²) — used for training / prefill w = torch.matmul(phi_q, phi_k.transpose(-2, -1)) # [B,H,S,S] kernel weights offset = 0 if past_kv is None else 0 qpos = torch.arange(S, device=x.device).view(S, 1) kpos = torch.arange(S, device=x.device).view(1, S) w = w.masked_fill((kpos > qpos).unsqueeze(0).unsqueeze(0), 0.0) w = w / w.sum(-1, keepdim=True).clamp_min(1e-6) y = torch.matmul(w, v) # [B,H,S,D] if use_cache: # build the recurrent state from the full prefix for later decoding state_kv = torch.einsum("bhsd,bhse->bhde", phi_k, v) # [B,H,D,D] state_z = phi_k.sum(2) # [B,H,D] return self._out(y), ("linear", state_kv, state_z) return self._out(y) # --------------------------------------------------------------------------- # # morphable wrapper: full + linear + learnable gate α # --------------------------------------------------------------------------- # class MorphableAttention(nn.Module): """Wraps the pretrained full attention with a linear twin and a per-layer gate. mode: 'mix' — H = α·H_full + (1-α)·H_lin (stages 1-2; captures alignment loss) 'full' — H = H_full (discretized: selected layer) 'linear' — H = H_lin (discretized: linearized layer) """ def __init__(self, full_attn, cfg, alpha_init=0.5): super().__init__() self.full = full_attn self.lin = LinearAttention(full_attn, cfg) # gate stored as a logit; α = sigmoid(logit) self.alpha_logit = nn.Parameter(torch.tensor(math.log(alpha_init / (1 - alpha_init)))) self.mode = "mix" self.last_hidden_align = None # ||H_lin - H_full||² captured on the last forward @property def alpha(self): return torch.sigmoid(self.alpha_logit) def freeze_full(self): for p in self.full.parameters(): p.requires_grad_(False) def forward(self, x, position_ids, past_kv=None, use_cache=False): if self.mode == "full": return self.full(x, position_ids, past_kv=past_kv, use_cache=use_cache) if self.mode == "linear": return self.lin(x, position_ids, past_kv=past_kv, use_cache=use_cache) # 'mix': run both paths (no cache during training/selection) h_full = self.full(x, position_ids) h_lin = self.lin(x, position_ids) self.last_hidden_align = ((h_lin - h_full) ** 2).mean() a = self.alpha h_mix = a * h_full + (1.0 - a) * h_lin if use_cache: return h_mix, None return h_mix # --------------------------------------------------------------------------- # # stage-3 discretization + loss helpers # --------------------------------------------------------------------------- # @torch.no_grad() def discretize(model, k_full): """Keep the top-k layers by α as full attention; linearize the rest. Returns the list of selected (full) layer indices.""" morphs = [layer.attn for layer in model.layers if isinstance(layer.attn, MorphableAttention)] alphas = torch.stack([m.alpha.detach() for m in morphs]) keep = set(torch.topk(alphas, min(k_full, len(morphs))).indices.tolist()) for i, m in enumerate(morphs): m.mode = "full" if i in keep else "linear" return sorted(keep) def hidden_alignment_loss(model): """L_hidden = mean_l ||H_lin^(l) - H_full^(l)||² (stage 1).""" terms = [layer.attn.last_hidden_align for layer in model.layers if isinstance(layer.attn, MorphableAttention) and layer.attn.last_hidden_align is not None] if not terms: return None return torch.stack(terms).mean() def linearization_reg(model): """L_reg = Σ_l α^(l) (stage 2 penalty that pushes layers toward linear).""" terms = [layer.attn.alpha for layer in model.layers if isinstance(layer.attn, MorphableAttention)] if not terms: return None return torch.stack(terms).sum() def set_mode(model, mode): for layer in model.layers: if isinstance(layer.attn, MorphableAttention): layer.attn.mode = mode