vtava's picture
Upload verified PDelta3-CLVR checkpoint for layers [3, 7, 11]
a38f163 verified
Raw History Blame
10.8 kB
"""Research adaptations of KDA and Gated DeltaNet-2 for frozen GQA projections.
Equations and deviations are documented in RESEARCH_LAYERS.md. This is an
independent PyTorch reference implementation, not the authors' fused kernels.
All state updates use float32 by default; no T-by-T matrix is constructed.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import torch
from torch import Tensor, nn
import torch.nn.functional as F
VARIANTS = ("cenn_kda", "cenn_delta2", "cenn_delta2_window")
@dataclass
class DeltaState:
memory: Tensor
keys: Tensor | None = None
values: Tensor | None = None
@property
def nbytes(self) -> int:
return sum(x.numel() * x.element_size()
for x in (self.memory, self.keys, self.values) if x is not None)
def delta_recurrence(q, k, z, erase, log_decay, memory, groups, chunk_size=32):
"""Asymmetric delta update, with a triangular solve inside each bounded chunk.
Sbar_t = diag(exp(log_decay_t)) S_{t-1}
S_t = Sbar_t + k_t (z_t - erase_t^T Sbar_t)^T.
q: [B,Hq,T,F]; other features: [B,Hkv,T,F]; z: [B,Hkv,T,Dv].
The chunk solve is algebraically identical to the token recurrence.
"""
if not 1 <= chunk_size <= 32:
raise ValueError("chunk_size must be in [1,32] for the bounded-decay reference")
outputs = []
for start in range(0, q.shape[2], chunk_size):
stop = min(start + chunk_size, q.shape[2])
kc, ec, zc = k[:, :, start:stop], erase[:, :, start:stop], z[:, :, start:stop]
decay = log_decay[:, :, start:stop].cumsum(dim=2).exp()
write = kc / decay
read = ec * decay
n = stop - start
interaction = torch.matmul(read, write.transpose(-1, -2))
lower = interaction.tril(diagonal=-1)
system = lower + torch.eye(n, device=q.device, dtype=q.dtype)
residual = torch.linalg.solve_triangular(
system, zc - torch.matmul(read, memory), upper=False, unitriangular=True
)
qr = q[:, :, start:stop] * decay.repeat_interleave(groups, dim=1)
wr = write.repeat_interleave(groups, dim=1)
rr = residual.repeat_interleave(groups, dim=1)
old = torch.matmul(qr, memory.repeat_interleave(groups, dim=1))
within = torch.matmul(qr, wr.transpose(-1, -2)).tril()
outputs.append(old + torch.matmul(within, rr))
memory = decay[:, :, -1, :, None] * (
memory + torch.matmul(write.transpose(-1, -2), residual)
)
return torch.cat(outputs, dim=2), memory
def delta_token_reference(q, k, z, erase, log_decay, memory, groups):
"""Independent, slow tokenwise oracle used for numerical validation."""
outputs = []
for t in range(q.shape[2]):
memory = log_decay[:, :, t].exp().unsqueeze(-1) * memory
old = torch.einsum("bhf,bhfv->bhv", erase[:, :, t], memory)
memory = memory + k[:, :, t, :, None] * (z[:, :, t] - old).unsqueeze(-2)
outputs.append(torch.einsum(
"bhf,bhfv->bhv", q[:, :, t], memory.repeat_interleave(groups, dim=1)
))
return torch.stack(outputs, dim=2), memory
def local_window_attention(q, k, v, window, groups):
"""Exact causal softmax over at most window keys, including the current token.
k/v may include up to window-1 cached prefix tokens. Unfold creates views;
scores occupy [B,Hq,T,window], never [B,Hq,T,T].
"""
k = k.repeat_interleave(groups, dim=1)
v = v.repeat_interleave(groups, dim=1)
total, t = k.shape[2], q.shape[2]
kw = F.pad(k, (0, 0, window - 1, 0)).unfold(2, window, 1)
vw = F.pad(v, (0, 0, window - 1, 0)).unfold(2, window, 1)
kw = kw[:, :, -t:].transpose(-1, -2)
vw = vw[:, :, -t:].transpose(-1, -2)
scores = torch.einsum("bhtd,bhtwd->bhtw", q, kw) / math.sqrt(q.shape[-1])
positions = torch.arange(total - t, total, device=q.device)
offsets = torch.arange(window, device=q.device) - window + 1
valid = positions[:, None] + offsets[None, :] >= 0
weights = scores.masked_fill(~valid[None, None], float("-inf")).softmax(-1)
return torch.einsum("bhtw,bhtwd->bhtd", weights, vw)
class ResearchCeNNLayer(nn.Module):
"""A GQA-compatible memory array with learned local state updates.
Inputs are frozen, post-RoPE Q/K and frozen V. K/V heads share memory exactly
as indicated by num_kv_heads. Query feature maps remain head-specific.
"""
def __init__(self, num_heads, num_kv_heads, head_dim, feature_dim=64,
variant="cenn_delta2", window=32, chunk_size=32):
super().__init__()
if variant not in VARIANTS:
raise ValueError(f"unknown variant: {variant}")
if min(num_heads, num_kv_heads, head_dim, feature_dim, window) < 1:
raise ValueError("dimensions and window must be positive")
if num_heads % num_kv_heads:
raise ValueError("query heads must be divisible by KV heads")
if not 1 <= chunk_size <= 32:
raise ValueError("chunk_size must be in [1,32]")
self.num_heads, self.num_kv_heads = num_heads, num_kv_heads
self.head_dim, self.feature_dim = head_dim, feature_dim
self.groups = num_heads // num_kv_heads
self.variant, self.window, self.chunk_size = variant, window, chunk_size
base = torch.zeros(num_kv_heads, feature_dim, head_dim)
for h in range(num_kv_heads):
if feature_dim == head_dim:
base[h] = torch.eye(head_dim)
else:
nn.init.orthogonal_(base[h])
self.wk = nn.Parameter(base.clone())
self.wq = nn.Parameter(base.repeat_interleave(self.groups, dim=0).clone())
self.forget_w = nn.Parameter(torch.zeros(num_kv_heads, feature_dim, head_dim))
# log(alpha) in [-0.25,0], initially -0.01. Bounds keep chunk rescaling safe.
self.forget_b = nn.Parameter(torch.full((num_kv_heads, feature_dim),
math.log(0.04 / 0.96)))
erase_dim = 1 if variant == "cenn_kda" else feature_dim
self.erase_w = nn.Parameter(torch.zeros(num_kv_heads, erase_dim, head_dim))
self.erase_b = nn.Parameter(torch.full((num_kv_heads, erase_dim), -1.0))
if variant != "cenn_kda":
self.write_w = nn.Parameter(torch.zeros(num_kv_heads, head_dim, head_dim))
self.write_b = nn.Parameter(torch.full((num_kv_heads, head_dim), -1.0))
self.log_gain = nn.Parameter(torch.zeros(num_heads))
if variant == "cenn_delta2_window":
self.mix_w = nn.Parameter(torch.zeros(num_heads, head_dim))
self.mix_b = nn.Parameter(torch.zeros(num_heads))
@property
def config(self):
return dict(num_heads=self.num_heads, num_kv_heads=self.num_kv_heads,
head_dim=self.head_dim, feature_dim=self.feature_dim,
variant=self.variant, window=self.window, chunk_size=self.chunk_size)
@staticmethod
def _project(x, weight, bias):
return torch.einsum("bhtd,hfd->bhtf", x, weight) + bias[None, :, None]
def features(self, q, k, v):
qn, kn = F.normalize(q, dim=-1), F.normalize(k, dim=-1)
qp = F.normalize(torch.einsum("bhtd,hfd->bhtf", qn, self.wq), dim=-1)
kp = F.normalize(torch.einsum("bhtd,hfd->bhtf", kn, self.wk), dim=-1)
log_decay = -0.25 * self._project(kn, self.forget_w, self.forget_b).sigmoid()
erase_gate = self._project(kn, self.erase_w, self.erase_b).sigmoid()
if self.variant == "cenn_kda":
write_gate = erase_gate
else:
write_gate = self._project(
F.normalize(v, dim=-1), self.write_w, self.write_b
).sigmoid()
return qp, kp, v * write_gate, kp * erase_gate, log_decay
def forward(self, q, k, v, state=None, return_state=False, implementation="chunk"):
if q.ndim != 4 or k.ndim != 4 or v.shape != k.shape:
raise ValueError("expected [batch,heads,time,dim] Q/K/V with equal K/V shapes")
if (q.shape[0] != k.shape[0] or q.shape[2:] != k.shape[2:]
or q.shape[1] != self.num_heads or k.shape[1] != self.num_kv_heads
or q.shape[-1] != self.head_dim or q.shape[2] < 1):
raise ValueError("Q/K/V shapes do not match the layer configuration")
q, k, v = (x.to(self.wq.dtype) for x in (q, k, v))
b = q.shape[0]
if state is None:
state = DeltaState(q.new_zeros(b, self.num_kv_heads,
self.feature_dim, self.head_dim))
qp, kp, z, erase, log_decay = self.features(q, k, v)
if implementation == "chunk":
output, memory = delta_recurrence(
qp, kp, z, erase, log_decay, state.memory, self.groups, self.chunk_size
)
elif implementation == "token":
output, memory = delta_token_reference(
qp, kp, z, erase, log_decay, state.memory, self.groups
)
else:
raise ValueError("implementation must be chunk or token")
output = output * self.log_gain.clamp(-4, 4).exp()[None, :, None, None]
new_state = DeltaState(memory)
if self.variant == "cenn_delta2_window":
keys = k if state.keys is None else torch.cat((state.keys, k), dim=2)
values = v if state.values is None else torch.cat((state.values, v), dim=2)
local = local_window_attention(q, keys, values, self.window, self.groups)
mix = (torch.einsum("bhtd,hd->bht", F.normalize(q, dim=-1), self.mix_w)
+ self.mix_b[None, :, None]).sigmoid().unsqueeze(-1)
output = mix * local + (1.0 - mix) * output
keep = self.window - 1
# Clone even contiguous slices: a view can retain the entire prefix storage.
new_state.keys = keys[:, :, -keep:].clone() if keep else keys.new_empty(
keys.shape[0], keys.shape[1], 0, keys.shape[-1])
new_state.values = values[:, :, -keep:].clone() if keep else values.new_empty(
values.shape[0], values.shape[1], 0, values.shape[-1])
return (output, new_state) if return_state else output
def recurrent_state_bytes(self, batch_size=1):
# Matrix and cache both use this module's floating-point dtype.
elements = self.num_kv_heads * self.feature_dim * self.head_dim
if self.variant == "cenn_delta2_window":
elements += 2 * (self.window - 1) * self.num_kv_heads * self.head_dim
return batch_size * elements * self.wq.element_size()
def softmax_reference(q, k, v, groups):
return F.scaled_dot_product_attention(
q, k.repeat_interleave(groups, dim=1), v.repeat_interleave(groups, dim=1),
dropout_p=0.0, is_causal=True
)