samueljayasingh commited on
Commit
81a32ee
·
verified ·
1 Parent(s): 5dc6be7

Upload Rune-R1 GRPO (RLVR) 351M reasoning model checkpoint

Browse files
Files changed (4) hide show
  1. README.md +78 -0
  2. config.json +13 -0
  3. model.py +215 -0
  4. pytorch_model.bin +3 -0
README.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ license: apache-2.0
5
+ tags:
6
+ - llm
7
+ - pytorch
8
+ - causal-lm
9
+ - rune-r1
10
+ - reasoning
11
+ - grpo
12
+ - rlvr
13
+ datasets:
14
+ - HuggingFaceFW/fineweb-edu
15
+ metrics:
16
+ - accuracy
17
+ ---
18
+
19
+ # Rune-R1 (351M) — GRPO Reasoning Model
20
+
21
+ **Rune-R1** is a ~351M parameter decoder-only transformer trained from scratch, then
22
+ aligned for math reasoning via a Pretrain -> SFT -> GRPO pipeline:
23
+
24
+ 1. **Pretraining**: 5.05B tokens of [FineWeb-Edu](https://huggingface.co/datasets/HuggingFaceFW/fineweb-edu) (final loss 2.999).
25
+ 2. **SFT**: supervised fine-tuning on math reasoning traces.
26
+ 3. **GRPO (RLVR)**: Group Relative Policy Optimization with PPO-style clipping and
27
+ KL-to-reference regularization, using a verifiable reward on math answer correctness
28
+ (reasoning-from-scratch style recipe). Trained for 2000 steps.
29
+
30
+ This checkpoint is the final GRPO policy from that last stage.
31
+
32
+ ## Model Details
33
+
34
+ - **Architecture**: Decoder-only Transformer
35
+ - **Parameters**: ~351M
36
+ - **Layers**: 22
37
+ - **Embedding Dimension**: 1024
38
+ - **Attention Heads / KV Groups**: 16 / 4 (Grouped-Query Attention)
39
+ - **Feed-Forward Hidden Dim**: 2816 (SwiGLU)
40
+ - **Position Embeddings**: RoPE (Rotary Position Embeddings)
41
+ - **Normalization**: RMSNorm (with QK Normalization)
42
+ - **Context Length**: 1024 tokens
43
+ - **Tokenizer**: GPT-2 (`tiktoken`)
44
+
45
+ ## Training (GRPO stage)
46
+
47
+ - **Steps**: 2000
48
+ - **Reward signal**: rule-based verifier reward on math answer correctness (RLVR)
49
+ - **Reference model**: frozen SFT checkpoint (KL penalty against drift)
50
+ - **Eval**: MATH-500 held-out set (50-example subset), evaluated every 100 steps
51
+
52
+ MATH-500 accuracy fluctuated in the 0-4% range over training (peak 4% at steps 1600
53
+ and 1900), reflecting the small model size and limited RL budget rather than a fully
54
+ converged reasoning model. Mean reward per step across training was ~0.016, with
55
+ occasional higher-reward rollouts (max single-step average 0.75).
56
+
57
+ ## Usage
58
+
59
+ You can load and generate text with this model using the `rune` package in this repository:
60
+
61
+ ```python
62
+ import torch
63
+ import tiktoken
64
+ from rune.model import CONFIG_350M, RuneModel
65
+
66
+ # Load model weights
67
+ ckpt = torch.load("pytorch_model.bin", map_location="cpu")
68
+ model = RuneModel(CONFIG_350M)
69
+ model.load_state_dict(ckpt)
70
+ model.eval()
71
+
72
+ # Encode prompt
73
+ enc = tiktoken.get_encoding("gpt2")
74
+ prompt = "The key to machine learning is"
75
+ tokens = torch.tensor([enc.encode(prompt)], dtype=torch.long)
76
+
77
+ # Generation logic using model(tokens)
78
+ ```
config.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "vocab_size": 50257,
3
+ "context_length": 1024,
4
+ "emb_dim": 1024,
5
+ "n_heads": 16,
6
+ "n_layers": 22,
7
+ "hidden_dim": 2816,
8
+ "head_dim": null,
9
+ "qk_norm": true,
10
+ "n_kv_groups": 4,
11
+ "rope_base": 10000.0,
12
+ "dtype": "torch.float32"
13
+ }
model.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture adapted from rasbt/LLMs-from-scratch pkg/llms_from_scratch/qwen3.py (Apache 2.0):
2
+ # RoPE + RMSNorm + SwiGLU + grouped-query attention, trimmed to a dense ~350M config
3
+ # with a GPT-2 (tiktoken) vocab instead of Qwen's tokenizer/MoE variants.
4
+ import torch
5
+ import torch.nn as nn
6
+
7
+ CONFIG_350M = {
8
+ "vocab_size": 50257, # tiktoken gpt2
9
+ "context_length": 1024,
10
+ "emb_dim": 1024,
11
+ "n_heads": 16,
12
+ "n_layers": 22,
13
+ "hidden_dim": 2816,
14
+ "head_dim": None, # defaults to emb_dim // n_heads
15
+ "qk_norm": True,
16
+ "n_kv_groups": 4,
17
+ "rope_base": 10_000.0,
18
+ "dtype": torch.float32, # fp32 master weights; train.py autocasts to bf16 for compute
19
+ }
20
+
21
+
22
+ class RMSNorm(nn.Module):
23
+ def __init__(self, emb_dim, eps=1e-6):
24
+ super().__init__()
25
+ self.eps = eps
26
+ self.scale = nn.Parameter(torch.ones(emb_dim))
27
+
28
+ def forward(self, x):
29
+ input_dtype = x.dtype
30
+ x = x.to(torch.float32)
31
+ variance = x.pow(2).mean(dim=-1, keepdim=True)
32
+ norm_x = x * torch.rsqrt(variance + self.eps) * self.scale
33
+ return norm_x.to(input_dtype)
34
+
35
+
36
+ def compute_rope_params(head_dim, theta_base, context_length, dtype=torch.float32):
37
+ assert head_dim % 2 == 0, "Head dimension must be even"
38
+ inv_freq = 1.0 / (theta_base ** (torch.arange(0, head_dim, 2, dtype=dtype) / head_dim))
39
+ positions = torch.arange(context_length, dtype=dtype)
40
+ angles = positions.unsqueeze(1) * inv_freq.unsqueeze(0)
41
+ angles = torch.cat([angles, angles], dim=1)
42
+ return torch.cos(angles), torch.sin(angles)
43
+
44
+
45
+ def apply_rope(x, cos, sin, offset=0):
46
+ # x: (batch, heads, seq_len, head_dim). `offset` is the absolute position
47
+ # of x[..., 0, :] — nonzero when x is a new chunk appended after cached
48
+ # positions, so rotation angles pick up where the cache left off.
49
+ head_dim = x.shape[-1]
50
+ x1, x2 = x[..., : head_dim // 2], x[..., head_dim // 2:]
51
+ seq_len = x.shape[2]
52
+ max_pos = cos.shape[0]
53
+ if offset + seq_len > max_pos:
54
+ offset = max(0, max_pos - seq_len)
55
+ cos = cos[offset:offset + seq_len].unsqueeze(0).unsqueeze(0)
56
+ sin = sin[offset:offset + seq_len].unsqueeze(0).unsqueeze(0)
57
+ rotated = torch.cat((-x2, x1), dim=-1)
58
+ return ((x * cos) + (rotated * sin)).to(dtype=x.dtype)
59
+
60
+
61
+ def new_kv_cache(n_layers):
62
+ """One mutable dict per layer; GroupedQueryAttention fills in 'k'/'v' and
63
+ grows them in place across calls sharing the same cache list."""
64
+ return [dict() for _ in range(n_layers)]
65
+
66
+
67
+ class GroupedQueryAttention(nn.Module):
68
+ def __init__(self, d_in, num_heads, num_kv_groups, head_dim=None, qk_norm=False, dtype=None):
69
+ super().__init__()
70
+ assert num_heads % num_kv_groups == 0, "num_heads must be divisible by num_kv_groups"
71
+ if head_dim is None:
72
+ assert d_in % num_heads == 0
73
+ head_dim = d_in // num_heads
74
+
75
+ self.num_heads = num_heads
76
+ self.num_kv_groups = num_kv_groups
77
+ self.group_size = num_heads // num_kv_groups
78
+ self.head_dim = head_dim
79
+ self.d_out = num_heads * head_dim
80
+
81
+ self.W_query = nn.Linear(d_in, self.d_out, bias=False, dtype=dtype)
82
+ self.W_key = nn.Linear(d_in, num_kv_groups * head_dim, bias=False, dtype=dtype)
83
+ self.W_value = nn.Linear(d_in, num_kv_groups * head_dim, bias=False, dtype=dtype)
84
+ self.out_proj = nn.Linear(self.d_out, d_in, bias=False, dtype=dtype)
85
+
86
+ self.q_norm = RMSNorm(head_dim) if qk_norm else None
87
+ self.k_norm = RMSNorm(head_dim) if qk_norm else None
88
+
89
+ def forward(self, x, mask, cos, sin, cache=None):
90
+ b, num_tokens, _ = x.shape
91
+
92
+ queries = self.W_query(x).view(b, num_tokens, self.num_heads, self.head_dim).transpose(1, 2)
93
+ keys = self.W_key(x).view(b, num_tokens, self.num_kv_groups, self.head_dim).transpose(1, 2)
94
+ values = self.W_value(x).view(b, num_tokens, self.num_kv_groups, self.head_dim).transpose(1, 2)
95
+
96
+ if self.q_norm:
97
+ queries = self.q_norm(queries)
98
+ if self.k_norm:
99
+ keys = self.k_norm(keys)
100
+
101
+ past_len = 0 if cache is None or cache.get("k") is None else cache["k"].shape[2]
102
+ queries = apply_rope(queries, cos, sin, offset=past_len)
103
+ keys = apply_rope(keys, cos, sin, offset=past_len)
104
+
105
+ if cache is not None:
106
+ if cache.get("k") is not None:
107
+ keys = torch.cat([cache["k"], keys], dim=2)
108
+ values = torch.cat([cache["v"], values], dim=2)
109
+ cache["k"], cache["v"] = keys, values
110
+
111
+ keys = keys.repeat_interleave(self.group_size, dim=1)
112
+ values = values.repeat_interleave(self.group_size, dim=1)
113
+
114
+ if past_len == 0:
115
+ # No cache, or first (prefill) call on an empty cache: query and
116
+ # key spans are identical, standard causal mask applies.
117
+ context = nn.functional.scaled_dot_product_attention(
118
+ queries, keys, values, attn_mask=None, is_causal=True
119
+ )
120
+ elif num_tokens == 1:
121
+ # Single-token decode step: this query is always the newest
122
+ # position, so it may attend to every cached key — no mask needed.
123
+ context = nn.functional.scaled_dot_product_attention(
124
+ queries, keys, values, attn_mask=None, is_causal=False
125
+ )
126
+ else:
127
+ raise NotImplementedError("cache only supports prefill-then-single-token decode")
128
+
129
+ context = context.transpose(1, 2).reshape(b, num_tokens, self.d_out)
130
+ return self.out_proj(context)
131
+
132
+
133
+ class FeedForward(nn.Module):
134
+ def __init__(self, cfg):
135
+ super().__init__()
136
+ self.fc1 = nn.Linear(cfg["emb_dim"], cfg["hidden_dim"], dtype=cfg["dtype"], bias=False)
137
+ self.fc2 = nn.Linear(cfg["emb_dim"], cfg["hidden_dim"], dtype=cfg["dtype"], bias=False)
138
+ self.fc3 = nn.Linear(cfg["hidden_dim"], cfg["emb_dim"], dtype=cfg["dtype"], bias=False)
139
+
140
+ def forward(self, x):
141
+ return self.fc3(nn.functional.silu(self.fc1(x)) * self.fc2(x))
142
+
143
+
144
+ class TransformerBlock(nn.Module):
145
+ def __init__(self, cfg):
146
+ super().__init__()
147
+ self.att = GroupedQueryAttention(
148
+ d_in=cfg["emb_dim"], num_heads=cfg["n_heads"], head_dim=cfg["head_dim"],
149
+ num_kv_groups=cfg["n_kv_groups"], qk_norm=cfg["qk_norm"], dtype=cfg["dtype"],
150
+ )
151
+ self.ff = FeedForward(cfg)
152
+ self.norm1 = RMSNorm(cfg["emb_dim"])
153
+ self.norm2 = RMSNorm(cfg["emb_dim"])
154
+
155
+ def forward(self, x, mask, cos, sin, cache=None):
156
+ x = x + self.att(self.norm1(x), mask, cos, sin, cache)
157
+ x = x + self.ff(self.norm2(x))
158
+ return x
159
+
160
+
161
+ class RuneModel(nn.Module):
162
+ def __init__(self, cfg):
163
+ super().__init__()
164
+ self.cfg = cfg
165
+ self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"], dtype=cfg["dtype"])
166
+ self.trf_blocks = nn.ModuleList(TransformerBlock(cfg) for _ in range(cfg["n_layers"]))
167
+ self.final_norm = RMSNorm(cfg["emb_dim"])
168
+ self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False, dtype=cfg["dtype"])
169
+
170
+ head_dim = cfg["head_dim"] or cfg["emb_dim"] // cfg["n_heads"]
171
+ cos, sin = compute_rope_params(head_dim, cfg["rope_base"], cfg["context_length"])
172
+ self.register_buffer("cos", cos, persistent=False)
173
+ self.register_buffer("sin", sin, persistent=False)
174
+
175
+ def forward(self, in_idx, cache=None):
176
+ x = self.tok_emb(in_idx)
177
+ for i, block in enumerate(self.trf_blocks):
178
+ x = block(x, None, self.cos, self.sin, cache[i] if cache is not None else None)
179
+ x = self.final_norm(x)
180
+ return self.out_head(x.to(self.cfg["dtype"]))
181
+
182
+
183
+ def _test_kv_cache_matches_full_forward(cfg):
184
+ torch.manual_seed(0)
185
+ model = RuneModel(cfg).eval()
186
+ seq = torch.randint(0, cfg["vocab_size"], (2, 12))
187
+
188
+ with torch.no_grad():
189
+ full_logits = model(seq)
190
+
191
+ cache = new_kv_cache(cfg["n_layers"])
192
+ chunks = [model(seq[:, :5], cache=cache)]
193
+ for i in range(5, 12):
194
+ chunks.append(model(seq[:, i:i + 1], cache=cache))
195
+ cached_logits = torch.cat(chunks, dim=1)
196
+
197
+ assert cached_logits.shape == full_logits.shape
198
+ max_diff = (full_logits - cached_logits).abs().max().item()
199
+ assert torch.allclose(full_logits, cached_logits, atol=1e-4), f"max diff {max_diff}"
200
+ print(f"kv-cache self-test ok (max diff vs full forward: {max_diff:.2e})")
201
+
202
+
203
+ if __name__ == "__main__":
204
+ cfg = CONFIG_350M
205
+ model = RuneModel(cfg)
206
+ n_params = sum(p.numel() for p in model.parameters())
207
+ print(f"params: {n_params:,} ({n_params / 1e6:.1f}M)")
208
+
209
+ x = torch.randint(0, cfg["vocab_size"], (2, 16))
210
+ logits = model(x)
211
+ assert logits.shape == (2, 16, cfg["vocab_size"]), logits.shape
212
+ assert torch.isfinite(logits).all()
213
+ print("forward pass ok:", logits.shape)
214
+
215
+ _test_kv_cache_matches_full_forward(dict(cfg, n_layers=2, context_length=64))
pytorch_model.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ebc064fddfb256972d56b9de079ef1d3853d09ed9b9733049ac24de82145ed0f
3
+ size 1403944823