File size: 8,819 Bytes
33dfbd2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# Architecture adapted from rasbt/LLMs-from-scratch pkg/llms_from_scratch/qwen3.py (Apache 2.0):
# RoPE + RMSNorm + SwiGLU + grouped-query attention, trimmed to a dense ~350M config
# with a GPT-2 (tiktoken) vocab instead of Qwen's tokenizer/MoE variants.
import torch
import torch.nn as nn

CONFIG_350M = {
    "vocab_size": 50257,        # tiktoken gpt2
    "context_length": 1024,
    "emb_dim": 1024,
    "n_heads": 16,
    "n_layers": 22,
    "hidden_dim": 2816,
    "head_dim": None,           # defaults to emb_dim // n_heads
    "qk_norm": True,
    "n_kv_groups": 4,
    "rope_base": 10_000.0,
    "dtype": torch.float32,     # fp32 master weights; train.py autocasts to bf16 for compute
}


class RMSNorm(nn.Module):
    def __init__(self, emb_dim, eps=1e-6):
        super().__init__()
        self.eps = eps
        self.scale = nn.Parameter(torch.ones(emb_dim))

    def forward(self, x):
        input_dtype = x.dtype
        x = x.to(torch.float32)
        variance = x.pow(2).mean(dim=-1, keepdim=True)
        norm_x = x * torch.rsqrt(variance + self.eps) * self.scale
        return norm_x.to(input_dtype)


def compute_rope_params(head_dim, theta_base, context_length, dtype=torch.float32):
    assert head_dim % 2 == 0, "Head dimension must be even"
    inv_freq = 1.0 / (theta_base ** (torch.arange(0, head_dim, 2, dtype=dtype) / head_dim))
    positions = torch.arange(context_length, dtype=dtype)
    angles = positions.unsqueeze(1) * inv_freq.unsqueeze(0)
    angles = torch.cat([angles, angles], dim=1)
    return torch.cos(angles), torch.sin(angles)


def apply_rope(x, cos, sin, offset=0):
    # x: (batch, heads, seq_len, head_dim). `offset` is the absolute position
    # of x[..., 0, :] — nonzero when x is a new chunk appended after cached
    # positions, so rotation angles pick up where the cache left off.
    head_dim = x.shape[-1]
    x1, x2 = x[..., : head_dim // 2], x[..., head_dim // 2:]
    seq_len = x.shape[2]
    cos = cos[offset:offset + seq_len].unsqueeze(0).unsqueeze(0)
    sin = sin[offset:offset + seq_len].unsqueeze(0).unsqueeze(0)
    rotated = torch.cat((-x2, x1), dim=-1)
    return ((x * cos) + (rotated * sin)).to(dtype=x.dtype)


def new_kv_cache(n_layers):
    """One mutable dict per layer; GroupedQueryAttention fills in 'k'/'v' and
    grows them in place across calls sharing the same cache list."""
    return [dict() for _ in range(n_layers)]


class GroupedQueryAttention(nn.Module):
    def __init__(self, d_in, num_heads, num_kv_groups, head_dim=None, qk_norm=False, dtype=None):
        super().__init__()
        assert num_heads % num_kv_groups == 0, "num_heads must be divisible by num_kv_groups"
        if head_dim is None:
            assert d_in % num_heads == 0
            head_dim = d_in // num_heads

        self.num_heads = num_heads
        self.num_kv_groups = num_kv_groups
        self.group_size = num_heads // num_kv_groups
        self.head_dim = head_dim
        self.d_out = num_heads * head_dim

        self.W_query = nn.Linear(d_in, self.d_out, bias=False, dtype=dtype)
        self.W_key = nn.Linear(d_in, num_kv_groups * head_dim, bias=False, dtype=dtype)
        self.W_value = nn.Linear(d_in, num_kv_groups * head_dim, bias=False, dtype=dtype)
        self.out_proj = nn.Linear(self.d_out, d_in, bias=False, dtype=dtype)

        self.q_norm = RMSNorm(head_dim) if qk_norm else None
        self.k_norm = RMSNorm(head_dim) if qk_norm else None

    def forward(self, x, mask, cos, sin, cache=None):
        b, num_tokens, _ = x.shape

        queries = self.W_query(x).view(b, num_tokens, self.num_heads, self.head_dim).transpose(1, 2)
        keys = self.W_key(x).view(b, num_tokens, self.num_kv_groups, self.head_dim).transpose(1, 2)
        values = self.W_value(x).view(b, num_tokens, self.num_kv_groups, self.head_dim).transpose(1, 2)

        if self.q_norm:
            queries = self.q_norm(queries)
        if self.k_norm:
            keys = self.k_norm(keys)

        past_len = 0 if cache is None or cache.get("k") is None else cache["k"].shape[2]
        queries = apply_rope(queries, cos, sin, offset=past_len)
        keys = apply_rope(keys, cos, sin, offset=past_len)

        if cache is not None:
            if cache.get("k") is not None:
                keys = torch.cat([cache["k"], keys], dim=2)
                values = torch.cat([cache["v"], values], dim=2)
            cache["k"], cache["v"] = keys, values

        keys = keys.repeat_interleave(self.group_size, dim=1)
        values = values.repeat_interleave(self.group_size, dim=1)

        if past_len == 0:
            # No cache, or first (prefill) call on an empty cache: query and
            # key spans are identical, standard causal mask applies.
            context = nn.functional.scaled_dot_product_attention(
                queries, keys, values, attn_mask=None, is_causal=True
            )
        elif num_tokens == 1:
            # Single-token decode step: this query is always the newest
            # position, so it may attend to every cached key — no mask needed.
            context = nn.functional.scaled_dot_product_attention(
                queries, keys, values, attn_mask=None, is_causal=False
            )
        else:
            raise NotImplementedError("cache only supports prefill-then-single-token decode")

        context = context.transpose(1, 2).reshape(b, num_tokens, self.d_out)
        return self.out_proj(context)


class FeedForward(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.fc1 = nn.Linear(cfg["emb_dim"], cfg["hidden_dim"], dtype=cfg["dtype"], bias=False)
        self.fc2 = nn.Linear(cfg["emb_dim"], cfg["hidden_dim"], dtype=cfg["dtype"], bias=False)
        self.fc3 = nn.Linear(cfg["hidden_dim"], cfg["emb_dim"], dtype=cfg["dtype"], bias=False)

    def forward(self, x):
        return self.fc3(nn.functional.silu(self.fc1(x)) * self.fc2(x))


class TransformerBlock(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.att = GroupedQueryAttention(
            d_in=cfg["emb_dim"], num_heads=cfg["n_heads"], head_dim=cfg["head_dim"],
            num_kv_groups=cfg["n_kv_groups"], qk_norm=cfg["qk_norm"], dtype=cfg["dtype"],
        )
        self.ff = FeedForward(cfg)
        self.norm1 = RMSNorm(cfg["emb_dim"])
        self.norm2 = RMSNorm(cfg["emb_dim"])

    def forward(self, x, mask, cos, sin, cache=None):
        x = x + self.att(self.norm1(x), mask, cos, sin, cache)
        x = x + self.ff(self.norm2(x))
        return x


class RuneModel(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.cfg = cfg
        self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"], dtype=cfg["dtype"])
        self.trf_blocks = nn.ModuleList(TransformerBlock(cfg) for _ in range(cfg["n_layers"]))
        self.final_norm = RMSNorm(cfg["emb_dim"])
        self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False, dtype=cfg["dtype"])

        head_dim = cfg["head_dim"] or cfg["emb_dim"] // cfg["n_heads"]
        cos, sin = compute_rope_params(head_dim, cfg["rope_base"], cfg["context_length"])
        self.register_buffer("cos", cos, persistent=False)
        self.register_buffer("sin", sin, persistent=False)

    def forward(self, in_idx, cache=None):
        x = self.tok_emb(in_idx)
        for i, block in enumerate(self.trf_blocks):
            x = block(x, None, self.cos, self.sin, cache[i] if cache is not None else None)
        x = self.final_norm(x)
        return self.out_head(x.to(self.cfg["dtype"]))


def _test_kv_cache_matches_full_forward(cfg):
    torch.manual_seed(0)
    model = RuneModel(cfg).eval()
    seq = torch.randint(0, cfg["vocab_size"], (2, 12))

    with torch.no_grad():
        full_logits = model(seq)

        cache = new_kv_cache(cfg["n_layers"])
        chunks = [model(seq[:, :5], cache=cache)]
        for i in range(5, 12):
            chunks.append(model(seq[:, i:i + 1], cache=cache))
        cached_logits = torch.cat(chunks, dim=1)

    assert cached_logits.shape == full_logits.shape
    max_diff = (full_logits - cached_logits).abs().max().item()
    assert torch.allclose(full_logits, cached_logits, atol=1e-4), f"max diff {max_diff}"
    print(f"kv-cache self-test ok (max diff vs full forward: {max_diff:.2e})")


if __name__ == "__main__":
    cfg = CONFIG_350M
    model = RuneModel(cfg)
    n_params = sum(p.numel() for p in model.parameters())
    print(f"params: {n_params:,} ({n_params / 1e6:.1f}M)")

    x = torch.randint(0, cfg["vocab_size"], (2, 16))
    logits = model(x)
    assert logits.shape == (2, 16, cfg["vocab_size"]), logits.shape
    assert torch.isfinite(logits).all()
    print("forward pass ok:", logits.shape)

    _test_kv_cache_matches_full_forward(dict(cfg, n_layers=2, context_length=64))