Skip to content

New Record: Paired Head Attention (-3.5s, -65 steps)#191

Merged
ClassicLarry merged 2 commits into
KellerJordan:masterfrom
ClassicLarry:paired_head_attn
Jan 7, 2026
Merged

New Record: Paired Head Attention (-3.5s, -65 steps)#191
ClassicLarry merged 2 commits into
KellerJordan:masterfrom
ClassicLarry:paired_head_attn

Conversation

@ClassicLarry

@ClassicLarry ClassicLarry commented Jan 7, 2026

Copy link
Copy Markdown
Collaborator

Updates in PR:

  • Paired head attention
  • 0.5s faster implementation of RoPE

The main idea behind paired head attention is to let queries attend to multiple representations of every position, and have multiple logits for the same position going into the same softmax. Implemented by interleaving the k, q, and v for pairs of heads to form twice as long sequences. EG [k1_h1, k2_h1, k3_h1], [k1_h2, k2_h2, k3_h2] -> [k1_h1, k1_h2, k2_h1, k2_h2, k3_h1, k3_h2], repeat for q and v

image

I update rotary() to run in a single line. Interestingly, pytorch is capable of computing x_flip without performing any data copying.

def rotary(self, x_BTHD):
    assert self.factor1.size(0) >= x_BTHD.size(-3)
    factor1, factor2 = (
        self.factor1[None, : x_BTHD.size(-3), None, :],
        self.factor2[None, : x_BTHD.size(-3), None, :],
    )
    x_flip = x_BTHD.view(*x_BTHD.shape[:-1], x_BTHD.shape[-1] // 2, 2).flip(-1).view(x_BTHD.shape)
    return factor1 * x_BTHD + factor2 * x_flip

I implemented a seperate custom rotary method for PairedHeadAttention, that enables the attention forward pass to save an extra parameter reshape copy:

# delay q,k reshape until rotary makes data contiguous, to enable view (non-copy)
q = q.view(B, T, self.num_heads // 2, self.head_dim * 2)
k = k.view(B, T, self.num_heads // 2, self.head_dim * 2)
v = v.reshape(B, T*2, self.num_heads//2, self.head_dim)

q, k = yarn.rotary(q), yarn.rotary(k)

q = q.view(B, T*2, self.num_heads//2, self.head_dim)
k = k.view(B, T*2, self.num_heads//2, self.head_dim)

I staggered the custom rotary for PairedHeadAttention such that head1 rotates at an offset from head 2. From limited testing this seemed better. Note that because the data is stored sequentially and attention applies a causal mask, query 4 from head 1 can only attend to keys 1-3 for head 2, whereas query 4 from head 2 can attend to keys 1-4 for head 1.

I double the max doc length and seqlens to account for concatenating two heads:

# paired head correction
seqlens = 2 * seqlens
max_len = 2 * max_len

I chose to not alter the window size. This means that window size of 128 is really only looking at 64 positions back (64 from head 1 and 64 from head2)

I chose to apply this to some of the short window layers, since I wasn't sure how it would interact with partial key offset on the longer windows. Kept adding to more layers and it kept getting better. I stopped after 4 (maybe more is better).

Seperately, I tested adding a mantissa to Adam, which seemed to give slight improvement. Leaving off this PR to keep the scope focused.

Timing and Validation

import scipy.stats
import torch

losses = [3.2793, 3.2796, 3.2783, 3.2782, 3.2784]
times = [108.695, 108.844, 108.775, 108.795, 108.87]

print("p=%.4f" % scipy.stats.ttest_1samp(losses, 3.28, alternative="less").pvalue)
# p=0.0063

print("losses:", torch.std_mean(torch.tensor(losses)))
# losses: (tensor(0.0006), tensor(3.2788))

print("time:", torch.std_mean(torch.tensor(times)))
# time: (tensor(0.0679), tensor(108.7958))

retiming prior record: 112.262 [112.187, 112.278, 112.321]

If no changes, will merge at 109.2s to be consistent with 3.5s improvement.

@Gusarich

Gusarich commented Jan 7, 2026

Copy link
Copy Markdown
Contributor

I think it'll make more sense to calculate relative improvements in % instead of absolute seconds for the track?

@ClassicLarry

Copy link
Copy Markdown
Collaborator Author

I think it'll make more sense to calculate relative improvements in % instead of absolute seconds for the track?

Do you mean in the readme show percent drop from the last record?

@ClassicLarry ClassicLarry merged commit 44b9dd7 into KellerJordan:master Jan 7, 2026
@falseywinchnet

Copy link
Copy Markdown

https://github.com/falseywinchnet/EpistemicGPT/blob/main/RecursiveNewGPT.ipynb

dont get distracted by the recursion- that's a new experiment.
and don't get distracted by the reasoning- that's another experiment.
focus solely on my attention module.

class Attention(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.n_embd = config.n_embd
        self.n_branch = config.n_branch
        self.block_size = config.block_size

        # Projections: Q and V produce NB branches, K is shared
        self.q_proj = nn.Linear(config.n_embd, config.n_embd * self.n_branch, bias=config.bias)
        self.k_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
        self.v_proj = nn.Linear(config.n_embd, config.n_embd * self.n_branch, bias=config.bias)
        self.o_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)

        self.register_buffer("mask", torch.tril(torch.ones(config.block_size, config.block_size))
                             .view(1, 1, config.block_size, config.block_size))
        
        # RoPE should handle the embedding dimension directly now
        self.rope = RoPE(self.n_embd, max_len=config.block_size)
        self.attn_drop = nn.Dropout(config.dropout)

    def forward(self, a, x):
        B, T, C = x.shape
        NB = self.n_branch

        # Project and reshape: (B, T, NB, C) -> (B, NB, T, C)
        q = self.q_proj(a).view(B, T, NB, C).transpose(1, 2)
        v = self.v_proj(a).view(B, T, NB, C).transpose(1, 2)
        # K has no branch dimension initially: (B, T, C) -> (B, 1, T, C)
        k = self.k_proj(x).view(B, T, 1, C).transpose(1, 2)
        
        # Apply RoPE
        q, k = self.rope(q), self.rope(k)

        # Raw scores: (B, NB, T, C) @ (B, 1, C, T) -> (B, NB, T, T)
        att = (q @ k.transpose(-2, -1)) / math.sqrt(C)

        # Causal Masking
        mask = self.mask[:, :, :T, :T]
        att = att.masked_fill(mask == 0, float("-inf"))

        # Branch Routing Logic (Softmax across the NB dimension)
        soft_probs = F.softmax(att, dim=1)
        soft_probs = torch.nan_to_num(soft_probs, nan=0.0) 

        # Straight-Through Estimator (STE)
        with torch.no_grad():
            max_val = soft_probs.max(dim=1, keepdim=True)[0]
            hard_mask = (soft_probs == max_val).float()
        
        route_mask = (hard_mask - soft_probs).detach() + soft_probs

        # Temporal Attention (Softmax across the T dimension)
        # Find global importance by taking max over branches
        att_max, _ = att.max(dim=1)
        attn_probs = F.softmax(att_max, dim=-1)
        attn_probs = self.attn_drop(attn_probs)

        # Composition: Combine temporal weights and branch router
        # (B, 1, T, T) * (B, NB, T, T) -> (B, NB, T, T)
        combined_weights = attn_probs.unsqueeze(1) * route_mask
        
        # Weighted sum of values and sum across branches: (B, NB, T, T) @ (B, NB, T, C)
        y = (combined_weights @ v).sum(dim=1) # (B, T, C)
        
        out = self.o_proj(y)
        return out

@falseywinchnet

Copy link
Copy Markdown

Observe how i'm routing V to select individual V from individual branch where individual Q -> K.
That's the principle. I call this Branched Routed Attention.

@ClassicLarry

ClassicLarry commented Jan 7, 2026

Copy link
Copy Markdown
Collaborator Author

I overlooked something when I refactored RoPE. It used to be [32 rotating, 32 stationary, 32 rotating, 32 stationary], and partial_key_offset is set to only apply to stationary dims. Now its [64 rotating, 64 stationary], but partial key offset is still applied to dims [32:64] and [96:128]. When I made this update I didn't notice a meaningful loss shift. So maybe it doesn't matter, but this may be performing slightly worse than if partial key offset is only applied to stationary dims. (In the prior PR I remember ablating only stationary vs only rotating dims and only stationary performed much better. But maybe a mix is fine too)

@0error-ob

Copy link
Copy Markdown

Nice work — really clean trick.

One thing I’m curious about from a downstream perspective:
when you let a query effectively aggregate multiple value paths (even implicitly), have you noticed any change in confidence calibration or over-reliance behavior, beyond speed/throughput?

I’m wondering whether some attention-level efficiency tricks also shift how “certain” the model behaves, not just how fast it runs.

@Gusarich

Gusarich commented Jan 8, 2026

Copy link
Copy Markdown
Contributor

I think it'll make more sense to calculate relative improvements in % instead of absolute seconds for the track?

Do you mean in the readme show percent drop from the last record?

for example here you calculated the relative change as -3.5s and used it to lock in in readme, but it would make more sense to calculate the relative change as -3.0876% and lock in readme time using that...

but i guess with readme times being in minutes this won't matter anytime soon..

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants