rootxhacker commited on
Commit
baff7c6
·
verified ·
1 Parent(s): 8d3191d

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,48 @@
1
  ---
2
  title: HobbyLM Playground
3
- emoji: 🏆
4
- colorFrom: red
5
  colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: HobbyLM Playground
3
+ emoji: 🪶
4
+ colorFrom: indigo
5
  colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 4.44.1
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ short_description: Chat, see & generate with the 500M HobbyLM MoE family
12
+ models:
13
+ - rootxhacker/HobbyLM-Base
14
+ - rootxhacker/HobbyLM-Chat
15
+ - rootxhacker/HobbyLM-Computer-Use
16
+ - rootxhacker/HobbyLM-Omni
17
+ - rootxhacker/HobbyLM-Diffusion
18
+ - rootxhacker/HobbyLM-Image
19
  ---
20
 
21
+ # 🪶 HobbyLM Playground
22
+
23
+ An interactive demo of **HobbyLM** — a from-scratch **500M sparse Mixture-of-Experts** language-model family
24
+ (plus a 333M text-to-image DiT), all trained on a hobby budget. One Space, three things to try:
25
+
26
+ - **💬 Chat** — talk to any variant: Base, Chat, Computer-Use, the multimodal Omni core, or the
27
+ masked-diffusion model (which decodes by iterative denoising, not left-to-right).
28
+ - **🖼️ Ask about an image** — upload a picture and question the multimodal **Omni** model (SigLIP2 vision
29
+ encoder → MoE LLM).
30
+ - **🎨 Generate an image** — text-to-image at 1024px with **HobbyLM-Image** (a flow-matching DiT in the
31
+ DC-AE latent space, conditioned on CLIP-L).
32
+
33
+ The models use a custom `hobbylm` architecture, so this Space vendors the small reference implementation
34
+ (`hobbylm/`, `hobby_image/`) rather than going through `transformers.AutoModel`.
35
+
36
+ ## Hardware
37
+
38
+ This Space is written for **ZeroGPU** (the heavy functions are decorated with `@spaces.GPU`). Enable
39
+ *ZeroGPU* in the Space's hardware settings for fast chat, image understanding, and 1024px generation. It
40
+ also runs on CPU (chat is slow; image generation is impractical there).
41
+
42
+ ## Links
43
+
44
+ - Models: <https://huggingface.co/rootxhacker>
45
+ - Code + the from-scratch Rust CPU engine: <https://github.com/harishsg993010/HobbyLM>
46
+
47
+ These are tiny research models — genuinely fluent and fun, but with the capability ceiling of a 500M model
48
+ (hallucination, weak strict-format following, soft hands / multi-person in image generation). Apache-2.0.
app.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HobbyLM Playground — a Gradio Space to chat with the HobbyLM models, ask questions about an
2
+ image (the multimodal Omni model), and generate images (the 1024px DiT + DC-AE pipeline).
3
+
4
+ All models are the from-scratch 500M sparse-MoE family (+ a 333M image DiT) published at
5
+ https://huggingface.co/rootxhacker . They use a custom architecture, so the Space vendors the
6
+ reference implementation (`hobbylm/`, `hobby_image/`) instead of going through transformers' AutoModel.
7
+
8
+ Runs on ZeroGPU (the heavy functions are @spaces.GPU); falls back to CPU when run locally.
9
+ """
10
+ import json
11
+ import threading
12
+
13
+ import gradio as gr
14
+ import torch
15
+ from huggingface_hub import hf_hub_download
16
+ from safetensors.torch import load_file
17
+
18
+ # ZeroGPU decorator — with a no-op fallback so the app also runs on plain CPU / locally.
19
+ try:
20
+ import spaces
21
+ except Exception: # not on a ZeroGPU Space
22
+ class _Spaces:
23
+ @staticmethod
24
+ def GPU(*a, **k):
25
+ if a and callable(a[0]):
26
+ return a[0]
27
+ def deco(f):
28
+ return f
29
+ return deco
30
+ spaces = _Spaces()
31
+
32
+ HF_USER = "rootxhacker"
33
+ VISION_ID = "google/siglip2-so400m-patch16-512" # the encoder HobbyLM-Omni was trained with
34
+ DCAE_ID = "mit-han-lab/dc-ae-f32c32-sana-1.1-diffusers"
35
+ CLIP_ID = "openai/clip-vit-large-patch14"
36
+ NEG_DEFAULT = "blurry, low quality, watermark, signature, text, jpeg artifacts, deformed, distorted"
37
+
38
+ # chat dropdown -> (repo suffix, decoding kind)
39
+ CHAT_MODELS = {
40
+ "HobbyLM-Chat — instruction / conversation": ("HobbyLM-Chat", "chat"),
41
+ "HobbyLM-Base — raw text completion": ("HobbyLM-Base", "base"),
42
+ "HobbyLM-Computer-Use — tools / GUI agent": ("HobbyLM-Computer-Use", "chat"),
43
+ "HobbyLM-Omni — multimodal core (text)": ("HobbyLM-Omni", "chat"),
44
+ "HobbyLM-Diffusion — masked-diffusion LM": ("HobbyLM-Diffusion", "diffusion"),
45
+ }
46
+
47
+ _cache = {}
48
+ _lock = threading.Lock()
49
+
50
+
51
+ def _device():
52
+ return "cuda" if torch.cuda.is_available() else "cpu"
53
+
54
+
55
+ def _enc():
56
+ import tiktoken
57
+ return tiktoken.get_encoding("gpt2")
58
+
59
+
60
+ # --------------------------------------------------------------------------- loaders (cached)
61
+
62
+ def _load_llm(repo, device):
63
+ key = ("llm", repo)
64
+ with _lock:
65
+ if key not in _cache:
66
+ from hobbylm.config import ModelConfig
67
+ from hobbylm.model import MoETransformer
68
+ cfg_d = {k: v for k, v in json.load(
69
+ open(hf_hub_download(f"{HF_USER}/{repo}", "config.json"))).items() if k != "preset"}
70
+ cfg = ModelConfig(**cfg_d)
71
+ cfg.expert_backend = "bmm" # universal MoE backend (CPU + GPU)
72
+ model = MoETransformer(cfg).eval()
73
+ model.load_state_dict(load_file(hf_hub_download(f"{HF_USER}/{repo}", "model.safetensors")))
74
+ _cache[key] = (model, cfg)
75
+ model, cfg = _cache[key]
76
+ model.to(device)
77
+ return model, cfg
78
+
79
+
80
+ def _load_vlm(device):
81
+ key = ("vlm",)
82
+ with _lock:
83
+ if key not in _cache:
84
+ from hobbylm.vision import SiglipVision
85
+ from hobbylm.multimodal import MoEVLM
86
+ llm, _ = _load_llm("HobbyLM-Omni", device)
87
+ enc = SiglipVision(model_id=VISION_ID, device=device, dtype=torch.float32)
88
+ vlm = MoEVLM(llm, vision_dim=enc.hidden)
89
+ vlm.mm_projector.load_state_dict(
90
+ load_file(hf_hub_download(f"{HF_USER}/HobbyLM-Omni", "vision_projector.safetensors")))
91
+ vlm.eval()
92
+ _cache[key] = (vlm, enc)
93
+ vlm, enc = _cache[key]
94
+ vlm.to(device)
95
+ return vlm, enc
96
+
97
+
98
+ def _load_image_models(device):
99
+ with _lock:
100
+ if ("dit",) not in _cache:
101
+ from hobby_image.dit import HobbyImageDiT, DiTConfig
102
+ cfg = json.load(open(hf_hub_download(f"{HF_USER}/HobbyLM-Image", "config.json")))
103
+ dit = HobbyImageDiT(DiTConfig(**cfg["dit_config"])).eval()
104
+ dit.load_state_dict(load_file(hf_hub_download(f"{HF_USER}/HobbyLM-Image", "model.safetensors")))
105
+ _cache[("dit",)] = (dit, cfg["dit_config"]["latent_h"], float(cfg["lat_std"]), float(cfg["scaling_factor"]))
106
+ if ("dcae",) not in _cache:
107
+ from diffusers import AutoencoderDC
108
+ _cache[("dcae",)] = AutoencoderDC.from_pretrained(DCAE_ID, torch_dtype=torch.float16).eval()
109
+ if ("clip",) not in _cache:
110
+ from transformers import CLIPTextModel, CLIPTokenizer
111
+ _cache[("clip",)] = (CLIPTokenizer.from_pretrained(CLIP_ID),
112
+ CLIPTextModel.from_pretrained(CLIP_ID, torch_dtype=torch.float16).eval())
113
+ dit, lat, lat_std, sf = _cache[("dit",)]
114
+ ae = _cache[("dcae",)]
115
+ tok, clip = _cache[("clip",)]
116
+ dit.to(device)
117
+ ae.to(device)
118
+ clip.to(device)
119
+ return dit, lat, lat_std, sf, ae, tok, clip
120
+
121
+
122
+ # --------------------------------------------------------------------------- chat
123
+
124
+ def _build_prompt(repo, message, history):
125
+ if repo == "HobbyLM-Base":
126
+ return message # base = pure completion
127
+ s = ""
128
+ for turn in history or []:
129
+ if isinstance(turn, (list, tuple)) and len(turn) == 2:
130
+ u, a = turn
131
+ if u:
132
+ s += f"USER: {u}\n"
133
+ if a:
134
+ s += f"ASSISTANT: {a}\n"
135
+ return s + f"USER: {message}\nASSISTANT:"
136
+
137
+
138
+ @spaces.GPU(duration=120)
139
+ def chat_fn(message, history, model_name, max_new, temperature):
140
+ from hobbylm.generate import generate as ar_generate
141
+ repo, kind = CHAT_MODELS[model_name]
142
+ dev = _device()
143
+ enc = _enc()
144
+ prompt = _build_prompt(repo, message, history)
145
+
146
+ if kind == "diffusion":
147
+ from hobbylm.diffusion import generate as dgen
148
+ model, _ = _load_llm(repo, dev)
149
+ ids = torch.tensor([enc.encode_ordinary(prompt)], device=dev)
150
+ gen_len = int(max_new)
151
+ out = dgen(model, ids, gen_len=gen_len, steps=max(32, 2 * gen_len),
152
+ temperature=max(0.0, float(temperature) - 0.4), rep_penalty=1.5, remask_steps=2)
153
+ return enc.decode(out[0].tolist()).strip()
154
+
155
+ model, cfg = _load_llm(repo, dev)
156
+ ids = torch.tensor([enc.encode_ordinary(prompt)], device=dev)
157
+ ctx_len = min(getattr(cfg, "context_length", 1024), 2048)
158
+ out = ar_generate(model, ids, int(max_new), float(temperature), 0, torch.device(dev),
159
+ top_p=0.95, repetition_penalty=1.3, no_repeat_ngram_size=3, ctx_len=ctx_len)
160
+ return enc.decode(out[0, ids.shape[1]:].tolist()).strip()
161
+
162
+
163
+ # --------------------------------------------------------------------------- image understanding (Omni)
164
+
165
+ @spaces.GPU(duration=120)
166
+ def understand_fn(image, question, max_new):
167
+ if image is None:
168
+ return "Please upload an image first."
169
+ from hobbylm.multimodal import IMAGE_TOKEN
170
+ from hobbylm.generate import GPT2_VALID, EOT
171
+ dev = _device()
172
+ enc = _enc()
173
+ vlm, venc = _load_vlm(dev)
174
+
175
+ with torch.no_grad():
176
+ feats = venc.encode([image.convert("RGB")]).float()
177
+ q = (question or "Describe this image in detail.").strip()
178
+ pre = enc.encode_ordinary(f"USER: {q}\nASSISTANT:")
179
+ ids = torch.tensor([[IMAGE_TOKEN] + pre], device=dev)
180
+ cur, _ = vlm.build_inputs_embeds(ids, image_features=feats)
181
+ outs = []
182
+ for _ in range(int(max_new)):
183
+ logits, _ = vlm.llm(inputs_embeds=cur)
184
+ lg = logits[:, -1, :].float()
185
+ lg[:, GPT2_VALID:] = -float("inf")
186
+ if outs: # repetition penalty
187
+ u = torch.tensor(sorted(set(outs)), device=dev)
188
+ v = lg[0, u]
189
+ lg[0, u] = torch.where(v > 0, v / 1.3, v * 1.3)
190
+ t = int(lg.argmax(-1).item())
191
+ if t == EOT:
192
+ break
193
+ outs.append(t)
194
+ e = vlm.llm.embed(torch.tensor([[t]], device=dev)).to(cur.dtype)
195
+ cur = torch.cat([cur, e], dim=1)
196
+ return enc.decode(outs).strip() or "(no answer)"
197
+
198
+
199
+ # --------------------------------------------------------------------------- image generation
200
+
201
+ @spaces.GPU(duration=180)
202
+ def generate_image_fn(prompt, negative, steps, guidance, seed, progress=gr.Progress()):
203
+ if not prompt or not prompt.strip():
204
+ raise gr.Error("Enter a prompt.")
205
+ from PIL import Image
206
+ import numpy as np
207
+ dev = _device()
208
+ dit, lat, lat_std, sf, ae, tok, clip = _load_image_models(dev)
209
+ steps = int(steps)
210
+ neg = (negative or "").strip()
211
+
212
+ def clip_encode(texts):
213
+ ids = tok(texts, padding="max_length", max_length=64, truncation=True,
214
+ return_tensors="pt").input_ids.to(dev)
215
+ with torch.no_grad():
216
+ return clip(ids).last_hidden_state.float()
217
+
218
+ g = torch.Generator(device=dev).manual_seed(int(seed))
219
+ ctx = clip_encode([prompt])
220
+ uncond = clip_encode([neg]) if neg else torch.zeros_like(ctx)
221
+ task = torch.zeros(1, dtype=torch.long, device=dev)
222
+ z = torch.randn(1, 32, lat, lat, generator=g, device=dev)
223
+ zs = torch.zeros(1, 32, lat, lat, device=dev)
224
+ em = torch.zeros(1, 1, lat, 2 * lat, device=dev)
225
+ amp = torch.autocast("cuda", dtype=torch.float16) if dev == "cuda" else torch.autocast("cpu", enabled=False)
226
+ with torch.no_grad():
227
+ for i in progress.tqdm(range(steps), desc="denoising"):
228
+ tt = torch.full((1,), i / steps, device=dev)
229
+ inp = torch.cat([torch.cat([z, zs], dim=-1), em, em], dim=1)
230
+ with amp:
231
+ vc = dit(inp, tt, ctx, task)[..., :lat].float()
232
+ vu = dit(inp, tt, uncond, task)[..., :lat].float()
233
+ z = z + (vu + float(guidance) * (vc - vu)) / steps
234
+ with amp:
235
+ img = ae.decode((z * lat_std / sf).half() if dev == "cuda" else (z * lat_std / sf)).sample
236
+ img = img.float().clamp(-1, 1)[0]
237
+ arr = ((img.permute(1, 2, 0).cpu().numpy() + 1) * 127.5).clip(0, 255).astype(np.uint8)
238
+ return Image.fromarray(arr)
239
+
240
+
241
+ # --------------------------------------------------------------------------- UI
242
+
243
+ INTRO = """# 🪶 HobbyLM Playground
244
+
245
+ A from-scratch **500M sparse Mixture-of-Experts** model family (+ a 333M image DiT), trained on a hobby
246
+ budget. Chat with any variant, ask questions about an image with the multimodal **Omni** model, or
247
+ generate a 1024px image. Models: [rootxhacker on Hugging Face](https://huggingface.co/rootxhacker) ·
248
+ code: [GitHub](https://github.com/harishsg993010/HobbyLM).
249
+
250
+ *These are tiny research models — fluent and fun, with the capability ceiling of a 500M model.*
251
+ """
252
+
253
+ with gr.Blocks(title="HobbyLM Playground", theme=gr.themes.Soft()) as demo:
254
+ gr.Markdown(INTRO)
255
+
256
+ with gr.Tab("💬 Chat"):
257
+ model_dd = gr.Dropdown(list(CHAT_MODELS), value=list(CHAT_MODELS)[0], label="Model")
258
+ with gr.Row():
259
+ max_new = gr.Slider(16, 512, value=200, step=8, label="Max new tokens")
260
+ temp = gr.Slider(0.0, 1.5, value=0.7, step=0.05, label="Temperature (0 = greedy)")
261
+ gr.ChatInterface(
262
+ fn=chat_fn,
263
+ additional_inputs=[model_dd, max_new, temp],
264
+ examples=[["Give me three tips for better sleep."],
265
+ ["Explain a mixture-of-experts model in one sentence."],
266
+ ["Write a short poem about the ocean."]],
267
+ )
268
+
269
+ with gr.Tab("🖼️ Ask about an image"):
270
+ gr.Markdown("Upload an image and ask the **HobbyLM-Omni** multimodal model about it.")
271
+ with gr.Row():
272
+ with gr.Column():
273
+ u_img = gr.Image(type="pil", label="Image")
274
+ u_q = gr.Textbox(label="Question", value="Describe this image in detail.")
275
+ u_max = gr.Slider(16, 128, value=48, step=8, label="Max new tokens")
276
+ u_btn = gr.Button("Ask", variant="primary")
277
+ u_out = gr.Textbox(label="Answer", lines=6)
278
+ u_btn.click(understand_fn, [u_img, u_q, u_max], u_out)
279
+
280
+ with gr.Tab("🎨 Generate an image"):
281
+ gr.Markdown("Text-to-image with **HobbyLM-Image** (1024px DiT in DC-AE latent space). "
282
+ "Strongest on single objects and cinematic scenes.")
283
+ with gr.Row():
284
+ with gr.Column():
285
+ g_prompt = gr.Textbox(label="Prompt", value="a red convertible car on a coastal road, golden hour")
286
+ g_neg = gr.Textbox(label="Negative prompt", value=NEG_DEFAULT)
287
+ with gr.Row():
288
+ g_steps = gr.Slider(20, 120, value=60, step=5, label="Steps")
289
+ g_cfg = gr.Slider(1.0, 10.0, value=5.0, step=0.5, label="Guidance (CFG)")
290
+ g_seed = gr.Number(value=1234, label="Seed", precision=0)
291
+ g_btn = gr.Button("Generate", variant="primary")
292
+ g_out = gr.Image(label="Result", height=512)
293
+ g_btn.click(generate_image_fn, [g_prompt, g_neg, g_steps, g_cfg, g_seed], g_out)
294
+ gr.Examples([["a photograph of a single red apple on a plain white background", NEG_DEFAULT, 60, 5.0, 1234],
295
+ ["a cozy library with tall wooden bookshelves, warm light", NEG_DEFAULT, 80, 5.0, 7],
296
+ ["a bowl of fresh strawberries, studio food photography", NEG_DEFAULT, 60, 5.0, 42]],
297
+ [g_prompt, g_neg, g_steps, g_cfg, g_seed])
298
+
299
+ if __name__ == "__main__":
300
+ demo.queue(max_size=20).launch()
hobby_image/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """hobby_image — the HobbyLM-Image text-to-image DiT (vendored for the Space)."""
hobby_image/dit.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DreamLite in-context DiT on a DC-AE f32c32 latent — the $300 path.
2
+
3
+ DC-AE compresses 32x spatially (vs VAE f8's 8x), so a 512px image -> 16x16x32 latent. The two-panel
4
+ canvas (target|source, width-concat) is 16x32x32 = ONLY 512 tokens (vs the conv U-Net's 64x128=8,192
5
+ positions). A DiT on 512 tokens is ~order-of-magnitude cheaper to train than the conv U-Net, which is
6
+ how the budget drops from ~$1.66k toward ~$300. Trade-off: f32 reconstruction is slightly weaker than
7
+ f8c16 on fine text (acceptable for the V0 "global + simple-local edits" scope).
8
+
9
+ Standard DiT (AdaLN-Zero on self-attn + MLP) + cross-attention to the frozen-VLM refiner tokens, flow-
10
+ matching velocity. Two-panel handled by a learned left/right panel embedding; loss uses the left half.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from dataclasses import dataclass
16
+
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+ from torch import Tensor
21
+
22
+
23
+ @dataclass
24
+ class DiTConfig:
25
+ in_channels: int = 34 # 32 DC-AE latent ch + 2 optional mask ch
26
+ out_channels: int = 32
27
+ latent_h: int = 16 # 512px / 32 = 16 (256px -> 8)
28
+ panel_w: int = 16 # per-panel width; canvas width = 2*panel_w
29
+ patch: int = 1 # DC-AE latent is already compressed -> patch 1
30
+ d_model: int = 1024
31
+ depth: int = 16
32
+ heads: int = 16
33
+ mlp_ratio: float = 3.0
34
+ ctx_dim: int = 1024
35
+ n_tasks: int = 3
36
+
37
+
38
+ def sinusoidal(t: Tensor, dim: int) -> Tensor:
39
+ half = dim // 2
40
+ f = torch.exp(-math.log(10000.0) * torch.arange(half, device=t.device) / half)
41
+ a = t.float()[:, None] * f[None, :]
42
+ return torch.cat([a.cos(), a.sin()], dim=-1)
43
+
44
+
45
+ def modulate(x: Tensor, shift: Tensor, scale: Tensor) -> Tensor:
46
+ return x * (1 + scale[:, None]) + shift[:, None]
47
+
48
+
49
+ class RMSNorm(nn.Module):
50
+ def __init__(self, dim: int, eps: float = 1e-6):
51
+ super().__init__()
52
+ self.weight = nn.Parameter(torch.ones(dim))
53
+ self.eps = eps
54
+
55
+ def forward(self, x: Tensor) -> Tensor:
56
+ return F.rms_norm(x, (x.size(-1),), self.weight, self.eps)
57
+
58
+
59
+ class Attention(nn.Module):
60
+ def __init__(self, dim: int, heads: int, ctx_dim: int | None = None, qk_norm: bool = True):
61
+ super().__init__()
62
+ self.heads = heads
63
+ self.hd = dim // heads
64
+ self.cross = ctx_dim is not None
65
+ self.q = nn.Linear(dim, dim, bias=False)
66
+ self.kv = nn.Linear(ctx_dim or dim, 2 * dim, bias=False)
67
+ self.o = nn.Linear(dim, dim, bias=False)
68
+ # QK-norm: RMSNorm on Q,K (over head_dim) BEFORE the dot product — the key training-stability
69
+ # fix for DiTs (Inf-DiT/Lumina-Next/SD3); bounds the attention logits, prevents divergence.
70
+ self.qn = RMSNorm(self.hd) if qk_norm else nn.Identity()
71
+ self.kn = RMSNorm(self.hd) if qk_norm else nn.Identity()
72
+
73
+ def forward(self, x: Tensor, ctx: Tensor | None = None) -> Tensor:
74
+ B, N, _ = x.shape
75
+ src = ctx if self.cross else x
76
+ q = self.qn(self.q(x).view(B, N, self.heads, self.hd)).transpose(1, 2)
77
+ kv = self.kv(src).view(B, src.shape[1], 2, self.heads, self.hd)
78
+ k = self.kn(kv[:, :, 0]).transpose(1, 2)
79
+ v = kv[:, :, 1].transpose(1, 2)
80
+ o = F.scaled_dot_product_attention(q, k, v)
81
+ return self.o(o.transpose(1, 2).reshape(B, N, -1))
82
+
83
+
84
+ class DiTBlock(nn.Module):
85
+ def __init__(self, cfg: DiTConfig):
86
+ super().__init__()
87
+ d = cfg.d_model
88
+ self.ln1 = nn.LayerNorm(d, elementwise_affine=False, eps=1e-6)
89
+ self.sa = Attention(d, cfg.heads)
90
+ self.lnc = nn.LayerNorm(d, eps=1e-6)
91
+ self.ca = Attention(d, cfg.heads, ctx_dim=cfg.ctx_dim)
92
+ self.ln2 = nn.LayerNorm(d, elementwise_affine=False, eps=1e-6)
93
+ h = int(d * cfg.mlp_ratio)
94
+ self.mlp = nn.Sequential(nn.Linear(d, h), nn.GELU(approximate="tanh"), nn.Linear(h, d))
95
+ self.adaln = nn.Sequential(nn.SiLU(), nn.Linear(d, 6 * d))
96
+ nn.init.zeros_(self.adaln[-1].weight); nn.init.zeros_(self.adaln[-1].bias)
97
+
98
+ def forward(self, x: Tensor, cond: Tensor, ctx: Tensor) -> Tensor:
99
+ sa_s, sa_sc, sa_g, ml_s, ml_sc, ml_g = self.adaln(cond).chunk(6, dim=-1)
100
+ x = x + sa_g[:, None] * self.sa(modulate(self.ln1(x), sa_s, sa_sc))
101
+ x = x + self.ca(self.lnc(x), ctx) # cross-attn to VLM tokens
102
+ x = x + ml_g[:, None] * self.mlp(modulate(self.ln2(x), ml_s, ml_sc))
103
+ return x
104
+
105
+
106
+ class HobbyImageDiT(nn.Module):
107
+ def __init__(self, cfg: DiTConfig):
108
+ super().__init__()
109
+ self.cfg = cfg
110
+ d = cfg.d_model
111
+ self.canvas_w = 2 * cfg.panel_w
112
+ self.n_tokens = (cfg.latent_h // cfg.patch) * (self.canvas_w // cfg.patch)
113
+ self.patch_embed = nn.Conv2d(cfg.in_channels, d, cfg.patch, stride=cfg.patch)
114
+ self.pos = nn.Parameter(torch.zeros(1, self.n_tokens, d))
115
+ self.panel = nn.Parameter(torch.zeros(2, d)) # left/right
116
+ self.t_mlp = nn.Sequential(nn.Linear(d, d), nn.SiLU(), nn.Linear(d, d))
117
+ self.task_emb = nn.Embedding(cfg.n_tasks, d)
118
+ self.blocks = nn.ModuleList([DiTBlock(cfg) for _ in range(cfg.depth)])
119
+ self.ln_f = nn.LayerNorm(d, elementwise_affine=False, eps=1e-6)
120
+ self.adaln_f = nn.Sequential(nn.SiLU(), nn.Linear(d, 2 * d))
121
+ nn.init.zeros_(self.adaln_f[-1].weight); nn.init.zeros_(self.adaln_f[-1].bias)
122
+ self.head = nn.Linear(d, cfg.patch * cfg.patch * cfg.out_channels)
123
+ nn.init.zeros_(self.head.weight); nn.init.zeros_(self.head.bias)
124
+ nn.init.trunc_normal_(self.pos, std=0.02)
125
+
126
+ def forward(self, x: Tensor, t: Tensor, ctx: Tensor, task: Tensor | None = None) -> Tensor:
127
+ """x: (B, in_ch, H, 2*panel_w) two-panel DC-AE latent. t: (B,). ctx: (B, M, ctx_dim).
128
+ Returns velocity (B, out_ch, H, 2*panel_w)."""
129
+ cfg = self.cfg
130
+ B, _, H, W = x.shape
131
+ h = self.patch_embed(x).flatten(2).transpose(1, 2) # (B, N, d)
132
+ h = h + self.pos
133
+ # panel embedding: tokens in the left half vs right half (by column)
134
+ gw = W // cfg.patch
135
+ col = torch.arange(self.n_tokens, device=x.device) % gw
136
+ left = (col < gw // 2)
137
+ h = h + torch.where(left[None, :, None], self.panel[0], self.panel[1])
138
+ cond = self.t_mlp(sinusoidal(t * 1000.0, cfg.d_model))
139
+ if task is not None:
140
+ cond = cond + self.task_emb(task)
141
+ for blk in self.blocks:
142
+ h = blk(h, cond, ctx)
143
+ s, sc = self.adaln_f(cond).chunk(2, dim=-1)
144
+ h = modulate(self.ln_f(h), s, sc)
145
+ h = self.head(h) # (B, N, p*p*out)
146
+ # unpatchify
147
+ gh = H // cfg.patch
148
+ h = h.view(B, gh, gw, cfg.patch, cfg.patch, cfg.out_channels)
149
+ h = h.permute(0, 5, 1, 3, 2, 4).reshape(B, cfg.out_channels, H, W)
150
+ return h
151
+
152
+
153
+ def count_params(m: nn.Module) -> int:
154
+ return sum(p.numel() for p in m.parameters())
155
+
156
+
157
+ # 512px: DC-AE f32 -> 16x16x32 per panel; two-panel canvas 16x32; 512 tokens.
158
+ V0_DCAE_512 = DiTConfig(in_channels=34, out_channels=32, latent_h=16, panel_w=16, patch=1,
159
+ d_model=1024, depth=16, heads=16, mlp_ratio=3.0, ctx_dim=1024)
160
+ # 256px pilot: 8x8x32 per panel; canvas 8x16; 128 tokens.
161
+ V0_DCAE_256 = DiTConfig(in_channels=34, out_channels=32, latent_h=8, panel_w=8, patch=1,
162
+ d_model=1024, depth=16, heads=16, mlp_ratio=3.0, ctx_dim=1024)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ for name, cfg in [("512", V0_DCAE_512), ("256", V0_DCAE_256)]:
167
+ m = HobbyImageDiT(cfg)
168
+ x = torch.randn(2, cfg.in_channels, cfg.latent_h, 2 * cfg.panel_w)
169
+ t = torch.rand(2); ctx = torch.randn(2, 256, cfg.ctx_dim); task = torch.zeros(2, dtype=torch.long)
170
+ with torch.no_grad():
171
+ y = m(x, t, ctx, task)
172
+ print(f"DiT {name}: {count_params(m)/1e6:.1f}M params | {m.n_tokens} tokens | "
173
+ f"in {tuple(x.shape)} -> out {tuple(y.shape)}")
hobbylm/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """hobbylm — core library package for the MoE LLM project.
2
+
3
+ Contains the model/MoE definitions, configs, optimizers, data pipelines,
4
+ multimodal encoders, generation/decoding, and tool/eval utilities that are
5
+ imported by the training, export, agents, eval, and Modal pipeline scripts.
6
+ """
hobbylm/config.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model + training configuration for the MoE lab.
2
+
3
+ A single ModelConfig drives the architecture; ablation knobs are explicit fields so an
4
+ ablation = one config override. PRESETS hold the 130M / 500M / 1B starting points
5
+ (exact dims are validated against targets by count_params.py).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field, asdict
10
+ from typing import Literal
11
+
12
+
13
+ @dataclass
14
+ class ModelConfig:
15
+ # ---- shape ----
16
+ vocab_size: int = 50304 # GPT-2 (50257) padded to mult of 128
17
+ d_model: int = 512
18
+ n_layers: int = 12
19
+ n_dense_layers: int = 1 # first-k layers use a dense FFN (DeepSeekMoE-16B style)
20
+ # ---- attention (GQA + QK-norm) ----
21
+ n_q_heads: int = 8
22
+ n_kv_heads: int = 2 # GQA group = n_q_heads / n_kv_heads
23
+ head_dim: int = 64 # may be decoupled from d_model (Qwen3 trick)
24
+ qk_norm: bool = True # RMSNorm per-head on Q,K before RoPE
25
+ rope_theta: float = 10000.0
26
+ attn_softcap: float = 0.0 # 0 disables; logit softcap fallback if not using qk_norm
27
+ # ---- FFN / experts (SwiGLU) ----
28
+ dense_ffn: int = 1536 # intermediate dim of the dense FFN layers
29
+ expert_ffn: int = 256 # intermediate dim of ONE routed/shared expert (fine-grained)
30
+ n_experts: int = 32 # routed experts per MoE layer
31
+ top_k: int = 4 # active routed experts per token
32
+ n_shared: int = 0 # always-on shared experts (0 or 1 at this scale)
33
+ # ---- routing / balancing (ablation forks) ----
34
+ gating: Literal["sigmoid", "softmax"] = "sigmoid"
35
+ norm_topk_prob: bool = False # ablation winner: NOT renormalizing top-k gates (OLMoE-style) beat renorm by -0.015
36
+ balancing: Literal["aux_free", "aux_loss"] = "aux_free"
37
+ aux_loss_coef: float = 1e-3 # global-batch balance loss (safety net w/ aux_free; 1e-2 if aux_loss only)
38
+ z_loss_coef: float = 1e-3 # router z-loss
39
+ bias_update_rate: float = 1e-3 # aux-loss-free per-expert bias step (u); annealed to 0 near end
40
+ router_init_std: float = 0.02 # router gets a small (0.1x-ish) init; see model init
41
+ # ---- embeddings / output ----
42
+ tie_embeddings: bool = True
43
+ scale_embeddings: bool = False # multiply token embeds by sqrt(d_model) (Gemma)
44
+ final_z_loss_coef: float = 1e-4 # z-loss on final logits
45
+ logit_softcap: float = 0.0 # 0 disables; e.g. 15-30 (Gemma-2)
46
+ # ---- diffusion conversion (LLaDA/MDLM: bidirectional attn + masked-token objective) ----
47
+ diffusion: bool = False # True => full bidirectional attention + masked-diffusion loss (no AR)
48
+ mask_token_id: int = 50257 # free sentinel in the 50304-padded vocab (>= GPT2_VALID, never an AR token)
49
+ mask_eps: float = 1e-3 # min mask ratio: t ~ U(eps, 1) per sequence
50
+ # ---- multi-token prediction (optional) ----
51
+ n_mtp: int = 0 # 0 disables; 1 = predict t+2 with one extra head
52
+ mtp_weight: float = 0.1
53
+ # ---- init ----
54
+ init_std: float = 0.02
55
+ # ---- impl ----
56
+ expert_backend: Literal["grouped", "bmm", "loop"] = "grouped" # grouped=GPU fast; bmm/loop=CPU-testable ref
57
+ # ---- throughput opts (nanogpt-inspired; see docs/ARCHITECTURE_RESEARCH.md §8) ----
58
+ fused_ce: bool = False # chunked cross-entropy: never materialize the full (T,vocab) fp32 logits
59
+ ce_chunk: int = 4096 # rows per CE chunk (fused_ce only)
60
+ fp8_head: bool = False # EXPERIMENTAL/BROKEN: FP8 lm_head (untied). No speedup at 1B + zero-grad
61
+ # backward (loss frozen at init in the 130M ablation). Do not use.
62
+ fp8_x_scale: float = 1.0 # fp8 activation/weight/grad scales (pre-head x is ~unit RMS, so 1.0 is safe)
63
+ fp8_w_scale: float = 1.0
64
+ fp8_grad_scale: float = 1.0
65
+
66
+ def __post_init__(self):
67
+ assert self.n_q_heads % self.n_kv_heads == 0, "n_q_heads must be divisible by n_kv_heads"
68
+ assert self.n_dense_layers <= self.n_layers
69
+ assert self.top_k <= self.n_experts
70
+
71
+ @property
72
+ def n_moe_layers(self) -> int:
73
+ return self.n_layers - self.n_dense_layers
74
+
75
+ def to_dict(self) -> dict:
76
+ return asdict(self)
77
+
78
+
79
+ @dataclass
80
+ class TrainConfig:
81
+ # data
82
+ data_dir: str = "data/fineweb10B"
83
+ train_pattern: str = "fineweb_train_*.bin"
84
+ val_pattern: str = "fineweb_val_*.bin"
85
+ seq_len: int = 1024
86
+ batch_tokens: int = 256 * 1024 # tokens per optimizer step (global)
87
+ micro_batch_seqs: int = 16 # sequences per micro-batch per GPU
88
+ # schedule
89
+ max_steps: int = 4000
90
+ warmup_steps: int = 100
91
+ cooldown_frac: float = 0.4 # last fraction of steps linearly decays lr -> lr*final_frac
92
+ final_lr_frac: float = 0.1
93
+ # optimizer
94
+ muon_lr: float = 0.02
95
+ muon_momentum: float = 0.95
96
+ muon_wd: float = 0.1
97
+ muon_ns_steps: int = 5
98
+ orthogonalizer: Literal["ns5", "polar"] = "ns5" # ns5=Newton-Schulz (baseline); polar=Polar Express schedule
99
+ adam_lr: float = 3e-4
100
+ adam_betas: tuple = (0.9, 0.95)
101
+ adam_wd: float = 0.1
102
+ grad_clip: float = 1.0
103
+ # bias anneal
104
+ bias_anneal_frac: float = 0.95 # disable aux-free bias updates after this fraction of steps
105
+ # eval / logging
106
+ val_every: int = 250
107
+ val_tokens: int = 10 * 1024 * 1024
108
+ log_every: int = 10
109
+ # run
110
+ seed: int = 1337
111
+ compile: bool = True
112
+ bf16: bool = True
113
+ out_dir: str = "runs"
114
+ run_name: str = "default"
115
+
116
+
117
+ # ---- preset architectures (starting points; tune with count_params.py) ----
118
+ PRESETS: dict[str, ModelConfig] = {
119
+ # dims tuned so TOTAL params hit targets (see count_params.py); G = dense_ffn/expert_ffn
120
+ "130M": ModelConfig( # ~140M total / ~62M active, G=8; top_k bumped 4->8 (ablation: -0.025 val loss)
121
+ d_model=512, n_layers=12, n_dense_layers=1,
122
+ n_q_heads=8, n_kv_heads=2, head_dim=64,
123
+ dense_ffn=1536, expert_ffn=192, n_experts=32, top_k=8, n_shared=0,
124
+ ),
125
+ "500M": ModelConfig( # ~500M total, G=7.2
126
+ d_model=768, n_layers=16, n_dense_layers=1,
127
+ n_q_heads=12, n_kv_heads=3, head_dim=128,
128
+ dense_ffn=2304, expert_ffn=320, n_experts=36, top_k=6, n_shared=1,
129
+ ),
130
+ "1B": ModelConfig( # ~1.02B total, G=12.6
131
+ d_model=1024, n_layers=20, n_dense_layers=1,
132
+ n_q_heads=16, n_kv_heads=8, head_dim=128,
133
+ dense_ffn=2816, expert_ffn=224, n_experts=64, top_k=8, n_shared=1,
134
+ ),
135
+ }
136
+
137
+
138
+ def get_config(preset: str) -> ModelConfig:
139
+ if preset not in PRESETS:
140
+ raise KeyError(f"unknown preset {preset!r}; choose from {list(PRESETS)}")
141
+ # return a copy so callers can mutate for ablations
142
+ return ModelConfig(**PRESETS[preset].to_dict())
hobbylm/diffusion.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure masked-diffusion (LLaDA / MDLM) conversion utilities for the MoE LLM.
2
+
3
+ The model itself only flips one thing for diffusion: attention becomes bidirectional
4
+ (model.py, gated on cfg.diffusion). Everything else lives here:
5
+
6
+ forward_mask : the forward (noising) process used at train time.
7
+ generate : iterative-denoising sampler with semi-autoregressive blocks.
8
+
9
+ The TRAIN loss is model.diffusion_cross_entropy (fused/chunked for big batches). The
10
+ unfused `diffusion_loss` below is for tests / sanity checks only.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import torch
15
+ import torch.nn.functional as F
16
+ from torch import Tensor
17
+
18
+
19
+ def forward_mask(input_ids: Tensor, mask_id: int, eps: float = 1e-3,
20
+ prompt_lens: Tensor | None = None, generator: torch.Generator | None = None):
21
+ """LLaDA forward process.
22
+
23
+ One mask ratio t ~ U(eps, 1) per sequence; mask each token iid with prob t.
24
+ Returns (noisy, labels, p_mask):
25
+ noisy : input with masked positions replaced by mask_id
26
+ labels : original token at masked positions, -1 (ignore_index) elsewhere
27
+ p_mask : per-token mask probability t (broadcast), used for the 1/p reweighting
28
+ `prompt_lens` (B,) optionally protects a prompt prefix from being masked/scored (SFT).
29
+ """
30
+ b, l = input_ids.shape
31
+ dev = input_ids.device
32
+ t = torch.rand(b, device=dev, generator=generator) * (1 - eps) + eps # (b,)
33
+ p_mask = t[:, None].expand(b, l).contiguous() # (b, l)
34
+ mask = torch.rand(b, l, device=dev, generator=generator) < p_mask
35
+ if prompt_lens is not None:
36
+ pos = torch.arange(l, device=dev)[None, :]
37
+ mask &= pos >= prompt_lens[:, None]
38
+ # guarantee >=1 masked token per sequence so no micro-batch contributes a zero loss
39
+ none_masked = ~mask.any(dim=1)
40
+ if none_masked.any():
41
+ rows = none_masked.nonzero(as_tuple=True)[0]
42
+ lo = 0 if prompt_lens is None else int(prompt_lens.min().item())
43
+ j = torch.randint(lo, l, (rows.numel(),), device=dev, generator=generator)
44
+ mask[rows, j] = True
45
+ noisy = torch.where(mask, torch.full_like(input_ids, mask_id), input_ids)
46
+ labels = torch.where(mask, input_ids, torch.full_like(input_ids, -1))
47
+ return noisy, labels, p_mask
48
+
49
+
50
+ def diffusion_loss(logits: Tensor, labels: Tensor, p_mask: Tensor) -> Tensor:
51
+ """Unfused LLaDA loss (tests): sum_{masked} CE / p_mask, normalized by B*L."""
52
+ b, l, _ = logits.shape
53
+ m = labels != -1
54
+ if int(m.sum()) == 0:
55
+ return logits.sum() * 0.0
56
+ ce = F.cross_entropy(logits[m].float(), labels[m], reduction="none")
57
+ return (ce / p_mask[m]).sum() / (b * l)
58
+
59
+
60
+ def get_num_transfer_tokens(n: int, steps: int) -> list[int]:
61
+ """Spread n unmask events as evenly as possible across `steps` (sums to n)."""
62
+ base = n // steps
63
+ out = [base] * steps
64
+ for i in range(n - base * steps):
65
+ out[i] += 1
66
+ return out
67
+
68
+
69
+ def add_gumbel_noise(logits: Tensor, temperature: float,
70
+ generator: torch.Generator | None = None) -> Tensor:
71
+ """Gumbel-max categorical sampling (LLaDA). argmax of this == a sample at `temperature`.
72
+ temperature<=0 -> identity (argmax == greedy)."""
73
+ if temperature <= 0:
74
+ return logits
75
+ logits = logits.to(torch.float64)
76
+ noise = torch.rand(logits.shape, dtype=torch.float64, device=logits.device, generator=generator)
77
+ gumbel = (-torch.log(noise + 1e-12)) ** temperature
78
+ return logits.exp() / gumbel
79
+
80
+
81
+ def _rep_penalty(blk: Tensor, present_ids: Tensor, penalty: float) -> Tensor:
82
+ """CTRL-style penalty across the canvas: damp logits of tokens already present (prompt +
83
+ committed) so the denoiser stops filling many slots with the same token. In-place on blk."""
84
+ if penalty == 1.0 or present_ids.numel() == 0:
85
+ return blk
86
+ col = blk[:, present_ids]
87
+ blk[:, present_ids] = torch.where(col > 0, col / penalty, col * penalty)
88
+ return blk
89
+
90
+
91
+ @torch.no_grad()
92
+ def generate(model, prompt_ids: Tensor, gen_len: int = 256, block: int = 32, steps: int = 64,
93
+ mask_id: int = 50257, temperature: float = 0.0, rep_penalty: float = 1.0,
94
+ remask_steps: int = 0, remask_frac: float = 0.3, valid_vocab: int = 50257,
95
+ eos_id: int | None = None, generator: torch.Generator | None = None) -> Tensor:
96
+ """Semi-autoregressive iterative denoising. prompt_ids: (1, P). Returns generated ids (1, <=gen_len).
97
+
98
+ Each block of `block` masked slots is filled over ~`steps*block/gen_len` steps, committing the
99
+ highest-confidence still-masked positions each step (low-confidence-remasking selection). Then
100
+ `remask_steps` refinement passes re-mask the lowest-confidence committed tokens and re-predict
101
+ them with full bidirectional context — this is what lets the model fix repetition/mistakes.
102
+ Sentinels (>= valid_vocab, incl. mask_id) are banned from being emitted. Blocks are causal
103
+ w.r.t. each other (a block attends to the committed prefix + itself), bidirectional within.
104
+ """
105
+ was_training = model.training
106
+ model.eval()
107
+ dev = prompt_ids.device
108
+ x = torch.cat([prompt_ids, torch.full((1, gen_len), mask_id, device=dev, dtype=prompt_ids.dtype)], dim=1)
109
+ P = prompt_ids.shape[1]
110
+
111
+ def block_logits(b1: int, b0: int) -> Tensor:
112
+ """Forward prefix+block; return (blk_len, V) logits with sentinels banned + rep-penalty."""
113
+ logits, _ = model(x[:, :b1])
114
+ blk = logits[0, b0:b1].float()
115
+ blk[:, valid_vocab:] = -float("inf") # never emit mask/sentinel ids
116
+ present = torch.unique(x[0, :b1])
117
+ present = present[(present < valid_vocab) & (present != mask_id)]
118
+ return _rep_penalty(blk, present, rep_penalty)
119
+
120
+ def predict(blk: Tensor):
121
+ prob = blk.softmax(-1)
122
+ pred = add_gumbel_noise(blk, temperature, generator).argmax(-1) if temperature > 0 else blk.argmax(-1)
123
+ return pred, prob
124
+
125
+ for b0 in range(P, P + gen_len, block):
126
+ b1 = min(b0 + block, P + gen_len)
127
+ blk_len = b1 - b0
128
+ sb = max(1, round(steps * blk_len / gen_len))
129
+ sched = get_num_transfer_tokens(blk_len, sb)
130
+ # --- fill: commit the most-confident still-masked positions over sb steps ---
131
+ for s in range(sb):
132
+ pred, prob = predict(block_logits(b1, b0))
133
+ conf = prob.gather(-1, pred.unsqueeze(-1)).squeeze(-1)
134
+ still = x[0, b0:b1] == mask_id
135
+ conf = torch.where(still, conf, torch.full_like(conf, -1.0))
136
+ k = min(sched[s], int(still.sum()))
137
+ if k <= 0:
138
+ continue
139
+ idx = conf.topk(k).indices
140
+ x[0, b0 + idx] = pred[idx].to(x.dtype)
141
+ # --- refine: re-mask the least-confident committed tokens and re-predict them ---
142
+ for _ in range(remask_steps):
143
+ blk = block_logits(b1, b0)
144
+ prob = blk.softmax(-1)
145
+ cur = x[0, b0:b1]
146
+ cur_conf = prob.gather(-1, cur.unsqueeze(-1)).squeeze(-1) # confidence in current tokens
147
+ r = max(1, int(blk_len * remask_frac))
148
+ x[0, b0 + cur_conf.topk(r, largest=False).indices] = mask_id
149
+ pred, _ = predict(block_logits(b1, b0))
150
+ still = (x[0, b0:b1] == mask_id).nonzero(as_tuple=True)[0]
151
+ x[0, b0 + still] = pred[still].to(x.dtype)
152
+ if eos_id is not None and bool((x[0, b0:b1] == eos_id).any()):
153
+ rel = int((x[0, b0:b1] == eos_id).nonzero(as_tuple=True)[0][0].item())
154
+ if was_training:
155
+ model.train()
156
+ return x[:, P:b0 + rel + 1]
157
+ if was_training:
158
+ model.train()
159
+ return x[:, P:]
hobbylm/generate.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference / text generation from a trained moe-lab checkpoint.
2
+
3
+ Loads a saved checkpoint (model.pt / ckpt_*.pt), rebuilds the model from its embedded config,
4
+ and autoregressively samples text with the GPT-2 (tiktoken) tokenizer.
5
+
6
+ python generate.py --ckpt runs/130M_10B/model.pt --prompt "The meaning of life is"
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from contextlib import nullcontext
12
+
13
+ import tiktoken
14
+ import torch
15
+ import torch.nn.functional as F
16
+
17
+ from .config import ModelConfig
18
+ from .model import MoETransformer
19
+
20
+ GPT2_VALID = 50257 # real GPT-2 tokens (rest of vocab is padding)
21
+ EOT = 50256 # <|endoftext|>
22
+
23
+
24
+ def load_model(ckpt_path: str, device):
25
+ ck = torch.load(ckpt_path, map_location=device, weights_only=False)
26
+ cfg_d = dict(ck["config"])
27
+ cfg_d.pop("preset", None)
28
+ cfg = ModelConfig(**cfg_d)
29
+ cfg.expert_backend = "grouped" if device.type == "cuda" else "bmm"
30
+ model = MoETransformer(cfg).to(device)
31
+ model.load_state_dict(ck["model"])
32
+ model.eval()
33
+ return model, cfg, ck.get("val_loss"), ck.get("step")
34
+
35
+
36
+ def _banned_ngram_tokens(prev: list[int], n: int) -> list[int]:
37
+ """Tokens that would complete an already-seen n-gram (no-repeat-ngram blocking)."""
38
+ if n <= 0 or len(prev) < n:
39
+ return []
40
+ seen: dict[tuple, list[int]] = {}
41
+ for i in range(len(prev) - n + 1):
42
+ ng = tuple(prev[i:i + n])
43
+ seen.setdefault(ng[:-1], []).append(ng[-1])
44
+ return seen.get(tuple(prev[-(n - 1):]), [])
45
+
46
+
47
+ @torch.no_grad()
48
+ def generate(model, idx, max_new_tokens, temperature, top_k, device,
49
+ top_p=0.95, repetition_penalty=1.3, no_repeat_ngram_size=3, ctx_len=1024):
50
+ amp = torch.autocast("cuda", dtype=torch.bfloat16) if device.type == "cuda" else nullcontext()
51
+ for _ in range(max_new_tokens):
52
+ idx_cond = idx[:, -ctx_len:]
53
+ with amp:
54
+ logits, _ = model(idx_cond)
55
+ logits = logits[:, -1, :].float()
56
+ logits[:, GPT2_VALID:] = -float("inf") # never emit padding tokens
57
+
58
+ seq = idx[0].tolist()
59
+ # repetition penalty (CTRL-style): damp logits of already-generated tokens
60
+ if repetition_penalty and repetition_penalty != 1.0:
61
+ uniq = torch.tensor(sorted(set(seq)), device=logits.device)
62
+ lg = logits[0, uniq]
63
+ logits[0, uniq] = torch.where(lg > 0, lg / repetition_penalty, lg * repetition_penalty)
64
+ # no-repeat n-gram blocking
65
+ for t in _banned_ngram_tokens(seq, no_repeat_ngram_size):
66
+ logits[0, t] = -float("inf")
67
+
68
+ if temperature > 0:
69
+ logits = logits / temperature
70
+ if top_k:
71
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
72
+ logits[logits < v[:, [-1]]] = -float("inf")
73
+ if top_p and top_p < 1.0: # nucleus filtering
74
+ s_logits, s_idx = torch.sort(logits, descending=True)
75
+ cum = torch.cumsum(F.softmax(s_logits, dim=-1), dim=-1)
76
+ rm = cum > top_p
77
+ rm[..., 1:] = rm[..., :-1].clone()
78
+ rm[..., 0] = False
79
+ logits[0, s_idx[0, rm[0]]] = -float("inf")
80
+ nxt = torch.multinomial(F.softmax(logits, dim=-1), 1)
81
+ else:
82
+ nxt = logits.argmax(-1, keepdim=True)
83
+ idx = torch.cat([idx, nxt], dim=1)
84
+ if nxt.item() == EOT:
85
+ break
86
+ return idx
87
+
88
+
89
+ def run(ckpt_path, prompts, max_new_tokens=120, temperature=0.9, top_k=0, device=None,
90
+ top_p=0.95, repetition_penalty=1.3, no_repeat_ngram_size=3):
91
+ device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
92
+ model, cfg, val_loss, step = load_model(ckpt_path, device)
93
+ enc = tiktoken.get_encoding("gpt2")
94
+ print(f"loaded {ckpt_path} | step={step} val_loss={val_loss} | "
95
+ f"d_model={cfg.d_model} layers={cfg.n_layers} experts={cfg.n_experts}/top{cfg.top_k}", flush=True)
96
+ print(f"sampling: temp={temperature} top_k={top_k} top_p={top_p} "
97
+ f"rep_penalty={repetition_penalty} no_repeat_ngram={no_repeat_ngram_size}\n", flush=True)
98
+ for p in prompts:
99
+ ids = torch.tensor([enc.encode_ordinary(p)], dtype=torch.long, device=device)
100
+ out = generate(model, ids, max_new_tokens, temperature, top_k, device,
101
+ top_p=top_p, repetition_penalty=repetition_penalty,
102
+ no_repeat_ngram_size=no_repeat_ngram_size)
103
+ text = enc.decode(out[0].tolist())
104
+ print("=" * 70)
105
+ print(f"PROMPT: {p!r}")
106
+ print(text)
107
+ print(flush=True)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ ap = argparse.ArgumentParser()
112
+ ap.add_argument("--ckpt", default="runs/130M_10B/model.pt")
113
+ ap.add_argument("--prompt", default=None)
114
+ ap.add_argument("--max_new_tokens", type=int, default=120)
115
+ ap.add_argument("--temperature", type=float, default=0.9)
116
+ ap.add_argument("--top_k", type=int, default=0)
117
+ ap.add_argument("--top_p", type=float, default=0.95)
118
+ ap.add_argument("--repetition_penalty", type=float, default=1.3)
119
+ ap.add_argument("--no_repeat_ngram_size", type=int, default=3)
120
+ args = ap.parse_args()
121
+ default_prompts = [
122
+ "The meaning of life is",
123
+ "Once upon a time, there was a",
124
+ "The capital of France is",
125
+ "In 2023, scientists discovered that",
126
+ "To make a good cup of coffee, you",
127
+ "The most important thing about climate change is",
128
+ ]
129
+ prompts = [args.prompt] if args.prompt else default_prompts
130
+ run(args.ckpt, prompts, args.max_new_tokens, args.temperature, args.top_k,
131
+ top_p=args.top_p, repetition_penalty=args.repetition_penalty,
132
+ no_repeat_ngram_size=args.no_repeat_ngram_size)
hobbylm/model.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The MoE transformer: GQA + QK-norm + RoPE attention, dense/MoE SwiGLU FFNs, tied head.
2
+
3
+ Pre-norm RMSNorm blocks. First cfg.n_dense_layers use a dense SwiGLU FFN; the rest use MoE.
4
+ Loss = cross-entropy + final-logit z-loss + sum of per-layer MoE aux/z losses.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import torch
10
+ import torch.distributed as dist
11
+ import torch.nn as nn
12
+ import torch.nn.functional as F
13
+ from torch import Tensor
14
+
15
+ from .config import ModelConfig
16
+ from .moe import MoE, SwiGLUWeights
17
+
18
+
19
+ # -----------------------------------------------------------------------------
20
+ # FP8 matmul for the lm_head (the single largest GEMM). Adapted from modded-nanogpt
21
+ # (@YouJiacheng): weight stored transposed (in, out) so the gradient w.r.t. the weight
22
+ # lands in the natural layout. Forward in e4m3, backward grad in e5m2. bf16 outputs.
23
+ # Wrapped as a custom op with an explicit autograd rule (torch._scaled_mm is not
24
+ # differentiable on its own).
25
+
26
+ @torch.library.custom_op("moelab::mm_t", mutates_args=())
27
+ def _mm_t(x: Tensor, w: Tensor, x_s: float, w_s: float, grad_s: float) -> tuple[Tensor, Tensor, Tensor]:
28
+ """y = x @ w with x:(M,in), w:(in,out). Returns (y_bf16, x_f8, w_f8) for backward reuse."""
29
+ @torch.compile
30
+ def impl(x: Tensor, w: Tensor):
31
+ x_f8 = x.div(x_s).to(torch.float8_e4m3fn)
32
+ w_f8 = w.div(w_s).to(torch.float8_e4m3fn)
33
+ w_col = w_f8.T.contiguous().T # _scaled_mm needs column-major B
34
+ out = torch._scaled_mm(x_f8, w_col, out_dtype=torch.bfloat16,
35
+ scale_a=x.new_tensor(x_s, dtype=torch.float32),
36
+ scale_b=x.new_tensor(w_s, dtype=torch.float32),
37
+ use_fast_accum=True)
38
+ return out, x_f8, w_f8
39
+ return impl(x, w)
40
+
41
+
42
+ @_mm_t.register_fake
43
+ def _(x: Tensor, w: Tensor, *_):
44
+ return x @ w, x.to(torch.float8_e4m3fn), w.to(torch.float8_e4m3fn)
45
+
46
+
47
+ @torch.library.custom_op("moelab::mm_t_backward", mutates_args=())
48
+ def _mm_t_backward(g: Tensor, x_f8: Tensor, w_f8: Tensor, x_s: float, w_s: float, grad_s: float) -> tuple[Tensor, Tensor]:
49
+ @torch.compile
50
+ def impl(g: Tensor, x_f8: Tensor, w_f8: Tensor):
51
+ x_scale = g.new_tensor(x_s, dtype=torch.float32)
52
+ w_scale = g.new_tensor(w_s, dtype=torch.float32)
53
+ g_scale = g.new_tensor(grad_s, dtype=torch.float32)
54
+ g_f8 = g.div(grad_s).to(torch.float8_e5m2)
55
+ grad_x = torch._scaled_mm(g_f8, w_f8.T, out_dtype=torch.bfloat16,
56
+ scale_a=g_scale, scale_b=w_scale, use_fast_accum=False)
57
+ grad_w = torch._scaled_mm(x_f8.T.contiguous(), g_f8.T.contiguous().T, out_dtype=torch.float32,
58
+ scale_a=x_scale, scale_b=g_scale, use_fast_accum=False)
59
+ return grad_x, grad_w
60
+ return impl(g, x_f8, w_f8)
61
+
62
+
63
+ @_mm_t_backward.register_fake
64
+ def _(g: Tensor, x_f8: Tensor, w_f8: Tensor, *_):
65
+ return x_f8.to(torch.bfloat16), w_f8.to(torch.float32)
66
+
67
+
68
+ def _mm_t_setup(ctx, inputs, output):
69
+ *_, x_s, w_s, grad_s = inputs
70
+ _, x_f8, w_f8 = output
71
+ ctx.save_for_backward(x_f8, w_f8)
72
+ ctx.scales = (x_s, w_s, grad_s)
73
+ ctx.set_materialize_grads(False)
74
+
75
+
76
+ def _mm_t_bwd(ctx, grad_out: Tensor, *_):
77
+ x_f8, w_f8 = ctx.saved_tensors
78
+ x_s, w_s, grad_s = ctx.scales
79
+ gx, gw = torch.ops.moelab.mm_t_backward(grad_out, x_f8, w_f8, x_s, w_s, grad_s)
80
+ return gx, gw, None, None, None
81
+
82
+
83
+ _mm_t.register_autograd(_mm_t_bwd, setup_context=_mm_t_setup)
84
+
85
+
86
+ class FP8Linear(nn.Module):
87
+ """Bias-free linear with FP8 matmul in training (CUDA) and a bf16 fallback elsewhere.
88
+ Weight is stored transposed (in_features, out_features)."""
89
+ def __init__(self, in_features: int, out_features: int, x_s=1.0, w_s=1.0, grad_s=1.0):
90
+ super().__init__()
91
+ self.in_features, self.out_features = in_features, out_features
92
+ self.x_s, self.w_s, self.grad_s = x_s, w_s, grad_s
93
+ self.weight = nn.Parameter(torch.empty(in_features, out_features))
94
+
95
+ def forward(self, x: Tensor) -> Tensor:
96
+ if self.training and x.is_cuda:
97
+ flat = x.flatten(0, -2).bfloat16().contiguous()
98
+ out = torch.ops.moelab.mm_t(flat, self.weight.bfloat16().contiguous(),
99
+ self.x_s, self.w_s, self.grad_s)[0]
100
+ return out.reshape(*x.shape[:-1], self.out_features)
101
+ return x @ self.weight.type_as(x)
102
+
103
+
104
+ # -----------------------------------------------------------------------------
105
+ # Fused cross-entropy: process the vocab projection in row-chunks under activation
106
+ # checkpointing so the full (T, vocab) fp32 logit tensor is never materialized or
107
+ # saved for backward. Numerically identical to a plain CE + final z-loss.
108
+
109
+ def _ce_chunk(x_c: Tensor, weight: Tensor, tgt_c: Tensor, softcap: float, tied: bool):
110
+ logits = (x_c @ weight.T) if tied else (x_c @ weight) # tied: weight (V,d); fp8 head: weight (d,V)
111
+ if softcap > 0:
112
+ logits = softcap * torch.tanh(logits / softcap)
113
+ logits = logits.float()
114
+ lse = torch.logsumexp(logits, dim=-1) # (c,)
115
+ z_sum = (lse * lse).sum()
116
+ valid = (tgt_c != -1)
117
+ tgt_logit = logits.gather(-1, tgt_c.clamp_min(0).unsqueeze(-1)).squeeze(-1)
118
+ ce_sum = ((lse - tgt_logit) * valid).sum()
119
+ return ce_sum, z_sum
120
+
121
+
122
+ def fused_cross_entropy(x: Tensor, weight: Tensor, targets: Tensor, *,
123
+ z_coef: float, softcap: float, chunk: int, tied: bool):
124
+ """Returns (loss = mean_ce + z_coef * mean(lse^2), z_mean.detach()). Memory-light."""
125
+ from torch.utils.checkpoint import checkpoint
126
+ T = x.shape[0]
127
+ n_valid = (targets != -1).sum().clamp_min(1)
128
+ ce_sum = x.new_zeros((), dtype=torch.float32)
129
+ z_sum = x.new_zeros((), dtype=torch.float32)
130
+ for i in range(0, T, chunk):
131
+ c_ce, c_z = checkpoint(_ce_chunk, x[i:i + chunk], weight, targets[i:i + chunk],
132
+ softcap, tied, use_reentrant=False)
133
+ ce_sum = ce_sum + c_ce
134
+ z_sum = z_sum + c_z
135
+ ce = ce_sum / n_valid
136
+ z_mean = z_sum / T
137
+ return ce + z_coef * z_mean, z_mean.detach()
138
+
139
+
140
+ # Masked-diffusion (LLaDA/MDLM) loss: cross-entropy on the masked positions only, reweighted
141
+ # by 1/p_mask, summed and normalized by the total token count (B*L). Chunked + activation-
142
+ # checkpointed like fused_cross_entropy so the (T, vocab) logits never fully materialize.
143
+
144
+ def _dce_chunk(x_c: Tensor, weight: Tensor, tgt_c: Tensor, pm_c: Tensor, softcap: float, tied: bool):
145
+ logits = (x_c @ weight.T) if tied else (x_c @ weight)
146
+ if softcap > 0:
147
+ logits = softcap * torch.tanh(logits / softcap)
148
+ logits = logits.float()
149
+ lse = torch.logsumexp(logits, dim=-1)
150
+ valid = (tgt_c != -1) # unmasked positions carry target -1
151
+ tgt_logit = logits.gather(-1, tgt_c.clamp_min(0).unsqueeze(-1)).squeeze(-1)
152
+ ce = (lse - tgt_logit) * valid / pm_c # 1/p_mask reweight; unmasked -> 0
153
+ return ce.sum()
154
+
155
+
156
+ def diffusion_cross_entropy(x: Tensor, weight: Tensor, targets: Tensor, p_mask: Tensor, *,
157
+ softcap: float, chunk: int, tied: bool):
158
+ """LLaDA loss = sum_{masked} CE / p_mask, normalized by B*L. Memory-light."""
159
+ from torch.utils.checkpoint import checkpoint
160
+ T = x.shape[0]
161
+ ce_sum = x.new_zeros((), dtype=torch.float32)
162
+ for i in range(0, T, chunk):
163
+ ce_sum = ce_sum + checkpoint(_dce_chunk, x[i:i + chunk], weight, targets[i:i + chunk],
164
+ p_mask[i:i + chunk], softcap, tied, use_reentrant=False)
165
+ return ce_sum / T
166
+
167
+
168
+ def rms_norm(x: Tensor, weight: Tensor | None = None, eps: float = 1e-6) -> Tensor:
169
+ out = F.rms_norm(x, (x.size(-1),), eps=eps)
170
+ return out * weight if weight is not None else out
171
+
172
+
173
+ class RMSNorm(nn.Module):
174
+ def __init__(self, dim: int, eps: float = 1e-6):
175
+ super().__init__()
176
+ self.weight = nn.Parameter(torch.ones(dim))
177
+ self.eps = eps
178
+
179
+ def forward(self, x: Tensor) -> Tensor:
180
+ return rms_norm(x, self.weight, self.eps)
181
+
182
+
183
+ def precompute_rope(head_dim: int, max_seq: int, theta: float, device) -> tuple[Tensor, Tensor]:
184
+ inv_freq = 1.0 / (theta ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim))
185
+ t = torch.arange(max_seq, device=device).float()
186
+ freqs = torch.outer(t, inv_freq) # (S, head_dim/2)
187
+ return freqs.cos(), freqs.sin() # each (S, head_dim/2)
188
+
189
+
190
+ def apply_rope(x: Tensor, cos: Tensor, sin: Tensor) -> Tensor:
191
+ # x: (B, H, S, D). Rotate-half formulation.
192
+ S, D = x.shape[-2], x.shape[-1]
193
+ cos = cos[:S].view(1, 1, S, D // 2)
194
+ sin = sin[:S].view(1, 1, S, D // 2)
195
+ x1, x2 = x[..., : D // 2], x[..., D // 2:]
196
+ return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1)
197
+
198
+
199
+ class Attention(nn.Module):
200
+ def __init__(self, cfg: ModelConfig):
201
+ super().__init__()
202
+ self.cfg = cfg
203
+ self.nq, self.nkv, self.hd = cfg.n_q_heads, cfg.n_kv_heads, cfg.head_dim
204
+ self.rep = self.nq // self.nkv
205
+ qkv_out = (self.nq + 2 * self.nkv) * self.hd
206
+ self.qkv = nn.Linear(cfg.d_model, qkv_out, bias=False)
207
+ self.proj = nn.Linear(self.nq * self.hd, cfg.d_model, bias=False)
208
+ if cfg.qk_norm:
209
+ self.q_norm = RMSNorm(self.hd)
210
+ self.k_norm = RMSNorm(self.hd)
211
+
212
+ def forward(self, x: Tensor, cos: Tensor, sin: Tensor) -> Tensor:
213
+ B, S, _ = x.shape
214
+ qkv = self.qkv(x)
215
+ q, k, v = qkv.split([self.nq * self.hd, self.nkv * self.hd, self.nkv * self.hd], dim=-1)
216
+ q = q.view(B, S, self.nq, self.hd).transpose(1, 2) # (B, nq, S, hd)
217
+ k = k.view(B, S, self.nkv, self.hd).transpose(1, 2)
218
+ v = v.view(B, S, self.nkv, self.hd).transpose(1, 2)
219
+ if self.cfg.qk_norm:
220
+ q, k = self.q_norm(q), self.k_norm(k) # per-head RMSNorm before RoPE
221
+ q, k = apply_rope(q, cos, sin), apply_rope(k, cos, sin)
222
+ # GQA: expand kv heads to match q heads
223
+ k = k.repeat_interleave(self.rep, dim=1)
224
+ v = v.repeat_interleave(self.rep, dim=1)
225
+ # diffusion (LLaDA) models see the whole noised canvas -> bidirectional; AR stays causal.
226
+ o = F.scaled_dot_product_attention(q, k, v, is_causal=not self.cfg.diffusion)
227
+ o = o.transpose(1, 2).reshape(B, S, self.nq * self.hd)
228
+ return self.proj(o)
229
+
230
+
231
+ class DenseFFN(nn.Module):
232
+ def __init__(self, cfg: ModelConfig):
233
+ super().__init__()
234
+ self.w13 = nn.Linear(cfg.d_model, 2 * cfg.dense_ffn, bias=False)
235
+ self.w2 = nn.Linear(cfg.dense_ffn, cfg.d_model, bias=False)
236
+
237
+ def forward(self, x: Tensor) -> Tensor:
238
+ gate, up = self.w13(x).chunk(2, dim=-1)
239
+ return self.w2(F.silu(gate) * up)
240
+
241
+
242
+ class Block(nn.Module):
243
+ def __init__(self, cfg: ModelConfig, layer_idx: int):
244
+ super().__init__()
245
+ self.attn_norm = RMSNorm(cfg.d_model)
246
+ self.attn = Attention(cfg)
247
+ self.ffn_norm = RMSNorm(cfg.d_model)
248
+ self.is_moe = layer_idx >= cfg.n_dense_layers
249
+ self.ffn = MoE(cfg) if self.is_moe else DenseFFN(cfg)
250
+
251
+ def forward(self, x: Tensor, cos: Tensor, sin: Tensor):
252
+ x = x + self.attn(self.attn_norm(x), cos, sin)
253
+ if self.is_moe:
254
+ out, aux = self.ffn(self.ffn_norm(x))
255
+ return x + out, aux
256
+ return x + self.ffn(self.ffn_norm(x)), x.new_zeros(())
257
+
258
+
259
+ class MoETransformer(nn.Module):
260
+ def __init__(self, cfg: ModelConfig):
261
+ super().__init__()
262
+ self.cfg = cfg
263
+ self.embed = nn.Embedding(cfg.vocab_size, cfg.d_model)
264
+ self.blocks = nn.ModuleList([Block(cfg, i) for i in range(cfg.n_layers)])
265
+ self.final_norm = RMSNorm(cfg.d_model)
266
+ # FP8 head must be untied (it stores the weight transposed, (d, vocab), for fp8 grad layout).
267
+ self.fp8_head = cfg.fp8_head
268
+ if cfg.fp8_head:
269
+ import warnings
270
+ warnings.warn("fp8_head is EXPERIMENTAL/BROKEN: zero gradient flows through the FP8 "
271
+ "backward (model won't train) and it gave no speedup. Use fused_ce instead.")
272
+ self.lm_head = FP8Linear(cfg.d_model, cfg.vocab_size,
273
+ x_s=cfg.fp8_x_scale, w_s=cfg.fp8_w_scale, grad_s=cfg.fp8_grad_scale)
274
+ else:
275
+ self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
276
+ if cfg.tie_embeddings:
277
+ self.lm_head.weight = self.embed.weight
278
+ self._rope_cache: dict = {}
279
+ self.apply(self._init)
280
+ if cfg.fp8_head:
281
+ # start the untied head from the (tied) embedding weights so the ablation isolates fp8,
282
+ # not a different head initialization.
283
+ with torch.no_grad():
284
+ self.lm_head.weight.copy_(self.embed.weight.t())
285
+ self._scale_residual_init()
286
+
287
+ # ---- init ----
288
+ def _init(self, m: nn.Module):
289
+ std = self.cfg.init_std
290
+ if isinstance(m, nn.Linear):
291
+ nn.init.normal_(m.weight, mean=0.0, std=std)
292
+ if m.bias is not None:
293
+ nn.init.zeros_(m.bias)
294
+ elif isinstance(m, nn.Embedding):
295
+ nn.init.normal_(m.weight, mean=0.0, std=std)
296
+ elif isinstance(m, SwiGLUWeights):
297
+ nn.init.normal_(m.w13, mean=0.0, std=std)
298
+ nn.init.normal_(m.w2, mean=0.0, std=std)
299
+
300
+ def _scale_residual_init(self):
301
+ # scale residual-projection weights by 1/sqrt(2*n_layers) (GPT-2/Megatron), critical deep-thin
302
+ scale = (2 * self.cfg.n_layers) ** -0.5
303
+ for blk in self.blocks:
304
+ with torch.no_grad():
305
+ blk.attn.proj.weight.mul_(scale)
306
+ if isinstance(blk.ffn, DenseFFN):
307
+ blk.ffn.w2.weight.mul_(scale)
308
+ else:
309
+ blk.ffn.experts.w2.mul_(scale)
310
+ if self.cfg.n_shared > 0:
311
+ blk.ffn.shared.w2.mul_(scale)
312
+ # small router init (~0.1x)
313
+ blk.ffn.gate.weight.mul_(0.1)
314
+
315
+ def rope(self, S: int, device, dtype):
316
+ key = (S, device, dtype)
317
+ if key not in self._rope_cache:
318
+ cos, sin = precompute_rope(self.cfg.head_dim, S, self.cfg.rope_theta, device)
319
+ self._rope_cache[key] = (cos.to(dtype), sin.to(dtype))
320
+ return self._rope_cache[key]
321
+
322
+ def forward(self, idx: Tensor | None = None, targets: Tensor | None = None,
323
+ inputs_embeds: Tensor | None = None, p_mask: Tensor | None = None):
324
+ # accept either token ids OR precomputed embeddings (inputs_embeds), for multimodal splicing.
325
+ if inputs_embeds is None:
326
+ x = self.embed(idx)
327
+ if self.cfg.scale_embeddings:
328
+ x = x * (self.cfg.d_model ** 0.5)
329
+ device = idx.device
330
+ else:
331
+ x = inputs_embeds
332
+ device = inputs_embeds.device
333
+ B, S = x.shape[0], x.shape[1]
334
+ cos, sin = self.rope(S, device, x.dtype)
335
+ aux_sum = x.new_zeros(())
336
+ for blk in self.blocks:
337
+ x, aux = blk(x, cos, sin)
338
+ aux_sum = aux_sum + aux
339
+ x = self.final_norm(x)
340
+ cfg = self.cfg
341
+ sc = cfg.logit_softcap
342
+
343
+ # ---- inference: return logits ----
344
+ if targets is None:
345
+ logits = self.lm_head(x)
346
+ if sc > 0:
347
+ logits = sc * torch.tanh(logits / sc)
348
+ return logits, aux_sum
349
+
350
+ # ---- training: compute loss ----
351
+ if cfg.diffusion:
352
+ # LLaDA masked-diffusion loss on the noised positions (input already masked upstream;
353
+ # targets hold the original token at masked positions, -1 elsewhere; p_mask = per-token t).
354
+ assert p_mask is not None, "diffusion forward needs p_mask (use diffusion.forward_mask)"
355
+ loss_ce = diffusion_cross_entropy(
356
+ x.reshape(-1, x.size(-1)), self.lm_head.weight, targets.reshape(-1),
357
+ p_mask.reshape(-1), softcap=sc, chunk=cfg.ce_chunk, tied=not cfg.fp8_head)
358
+ loss = loss_ce + aux_sum
359
+ return loss, {"ce": loss_ce.detach(), "aux": aux_sum.detach(), "z": x.new_zeros(())}
360
+
361
+ if cfg.fused_ce and not cfg.fp8_head:
362
+ # chunked CE on the tied weight (V, d); never materializes the full fp32 logits.
363
+ loss_cez, z = fused_cross_entropy(
364
+ x.reshape(-1, x.size(-1)), self.lm_head.weight, targets.reshape(-1),
365
+ z_coef=cfg.final_z_loss_coef, softcap=sc, chunk=cfg.ce_chunk, tied=True)
366
+ loss = loss_cez + aux_sum
367
+ ce = (loss_cez - cfg.final_z_loss_coef * z).detach()
368
+ return loss, {"ce": ce, "aux": aux_sum.detach(), "z": z}
369
+
370
+ logits = self.lm_head(x) # fp8 head -> bf16, else nn.Linear
371
+ if sc > 0:
372
+ logits = sc * torch.tanh(logits / sc)
373
+ logits = logits.float()
374
+ ce = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
375
+ z = (torch.logsumexp(logits, dim=-1) ** 2).mean()
376
+ loss = ce + cfg.final_z_loss_coef * z + aux_sum
377
+ return loss, {"ce": ce.detach(), "aux": aux_sum.detach(), "z": z.detach()}
378
+
379
+ @torch.no_grad()
380
+ def set_bias_update_rate(self, rate: float):
381
+ for blk in self.blocks:
382
+ if isinstance(blk.ffn, MoE):
383
+ blk.ffn.bias_update_rate = rate
384
+
385
+ @torch.no_grad()
386
+ def sync_expert_bias(self):
387
+ """Average aux-free bias buffers across DDP ranks so they stay identical
388
+ (each rank updates from local token counts; DDP doesn't sync buffers)."""
389
+ if not (dist.is_available() and dist.is_initialized()):
390
+ return
391
+ world = dist.get_world_size()
392
+ for blk in self.blocks:
393
+ if isinstance(blk.ffn, MoE):
394
+ dist.all_reduce(blk.ffn.expert_bias, op=dist.ReduceOp.SUM)
395
+ blk.ffn.expert_bias.div_(world)
396
+
397
+
398
+ def count_params(model: MoETransformer) -> dict:
399
+ cfg = model.cfg
400
+ total = sum(p.numel() for p in model.parameters())
401
+ # subtract tied head double-count is already avoided (shared weight counted once).
402
+ # fp8_head forces an untied head, so it costs a second vocab x d_model matrix.
403
+ tied = cfg.tie_embeddings and not cfg.fp8_head
404
+ embed = cfg.vocab_size * cfg.d_model * (1 if tied else 2)
405
+ # active = total - inactive routed experts. Per MoE layer, only top_k of n_experts run.
406
+ per_expert = cfg.d_model * 2 * cfg.expert_ffn + cfg.expert_ffn * cfg.d_model
407
+ inactive_per_moe = (cfg.n_experts - cfg.top_k) * per_expert
408
+ active = total - cfg.n_moe_layers * inactive_per_moe
409
+ return {"total": total, "active": active, "embed": embed,
410
+ "active_pct": 100 * active / total, "sparsity": total / active}
hobbylm/moe.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mixture-of-Experts layer: fp32 router, dropless expert compute, load balancing.
2
+
3
+ Backends for the expert compute (selected by cfg.expert_backend):
4
+ - "grouped": sort tokens by expert -> torch grouped_mm -> scatter. Fast on H100/A100, bf16.
5
+ - "bmm": loop-free reference using per-expert masked matmuls. CPU-testable.
6
+ - "loop": explicit python loop over experts. Slowest, clearest reference.
7
+
8
+ Balancing:
9
+ - "aux_free": DeepSeek-V3 gradient-free per-expert bias added to the SELECTION scores only
10
+ (not the gate weights), updated by sign(load error). Plus a tiny aux loss safety net.
11
+ - "aux_loss": classic Switch/OLMoE load-balance loss (coef ~1e-2) only.
12
+ Both add a router z-loss.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+ from torch import Tensor
20
+
21
+ from .config import ModelConfig
22
+
23
+
24
+ def _grouped_mm(a: Tensor, b: Tensor, offs: Tensor) -> Tensor:
25
+ """a: (T, d_in) expert-sorted; b: (E, d_in, d_out); offs: (E,) int32 cumulative row counts.
26
+ grouped_mm requires bf16 inputs; cast here (autocast does not cover this custom op)."""
27
+ fn = getattr(F, "grouped_mm", None) or getattr(torch, "_grouped_mm", None)
28
+ if fn is None:
29
+ raise RuntimeError("grouped_mm not available in this torch build; use expert_backend='bmm'")
30
+ return fn(a.bfloat16(), b.bfloat16(), offs=offs)
31
+
32
+
33
+ class SwiGLUWeights(nn.Module):
34
+ """A stack of E SwiGLU experts. w13: (E, d, 2f) gate+up fused; w2: (E, f, d) down."""
35
+ def __init__(self, n_experts: int, d_model: int, ffn: int):
36
+ super().__init__()
37
+ self.n_experts = n_experts
38
+ self.d_model = d_model
39
+ self.ffn = ffn
40
+ self.w13 = nn.Parameter(torch.empty(n_experts, d_model, 2 * ffn))
41
+ self.w2 = nn.Parameter(torch.empty(n_experts, ffn, d_model))
42
+
43
+ def expert_glu(self, x: Tensor, e: int) -> Tensor:
44
+ h = x @ self.w13[e]
45
+ gate, up = h.chunk(2, dim=-1)
46
+ return (F.silu(gate) * up) @ self.w2[e]
47
+
48
+
49
+ class MoE(nn.Module):
50
+ def __init__(self, cfg: ModelConfig):
51
+ super().__init__()
52
+ self.cfg = cfg
53
+ self.n_experts = cfg.n_experts
54
+ self.top_k = cfg.top_k
55
+ self.n_shared = cfg.n_shared
56
+ self.backend = cfg.expert_backend
57
+
58
+ # fp32 router (gate). Initialized small in model init.
59
+ self.gate = nn.Linear(cfg.d_model, cfg.n_experts, bias=False)
60
+ self.experts = SwiGLUWeights(cfg.n_experts, cfg.d_model, cfg.expert_ffn)
61
+ if cfg.n_shared > 0:
62
+ self.shared = SwiGLUWeights(cfg.n_shared, cfg.d_model, cfg.expert_ffn)
63
+
64
+ # aux-loss-free per-expert routing bias (no grad), updated by sign(load error)
65
+ self.register_buffer("expert_bias", torch.zeros(cfg.n_experts))
66
+ self.bias_update_rate = cfg.bias_update_rate # set to 0 to freeze (anneal at end of training)
67
+
68
+ # ---- routing (always fp32, even under bf16 autocast) ----
69
+ def _route(self, x: Tensor):
70
+ """x: (T, d). Returns topi (T,k) long, topv (T,k) fp32 gate weights, aux_loss scalar."""
71
+ cfg = self.cfg
72
+ with torch.autocast(device_type=x.device.type, enabled=False):
73
+ logits = F.linear(x.float(), self.gate.weight.float()) # fp32 router
74
+ scores = torch.sigmoid(logits) if cfg.gating == "sigmoid" else torch.softmax(logits, dim=-1)
75
+
76
+ # selection scores: add gradient-free bias (aux_free) for balanced routing
77
+ sel = scores + self.expert_bias.float() if cfg.balancing == "aux_free" else scores
78
+ topi = torch.topk(sel, self.top_k, dim=-1).indices # (T, k)
79
+ topv = torch.gather(scores, -1, topi) # gate weights from ORIGINAL scores
80
+ if cfg.norm_topk_prob:
81
+ topv = topv / (topv.sum(-1, keepdim=True) + 1e-9)
82
+
83
+ # ---- balancing losses ----
84
+ T = x.shape[0]
85
+ counts = torch.bincount(topi.reshape(-1), minlength=self.n_experts).float()
86
+ f_i = counts / (T * self.top_k)
87
+ P_i = scores.mean(dim=0)
88
+ aux = self.n_experts * (f_i.detach() * P_i).sum()
89
+ z_loss = (torch.logsumexp(logits, dim=-1) ** 2).mean()
90
+ aux_total = cfg.aux_loss_coef * aux + cfg.z_loss_coef * z_loss
91
+
92
+ # update aux-free bias (no grad): under-loaded experts up, over-loaded down
93
+ if cfg.balancing == "aux_free" and self.training and self.bias_update_rate > 0:
94
+ with torch.no_grad():
95
+ ideal = T * self.top_k / self.n_experts
96
+ self.expert_bias.add_(self.bias_update_rate * torch.sign(ideal - counts))
97
+
98
+ return topi, topv, aux_total
99
+
100
+ # ---- expert compute backends ----
101
+ def _experts_grouped(self, x: Tensor, topi: Tensor, topv: Tensor) -> Tensor:
102
+ T, d = x.shape
103
+ k = self.top_k
104
+ flat_e = topi.reshape(-1)
105
+ flat_tok = torch.arange(T, device=x.device).repeat_interleave(k)
106
+ order = torch.argsort(flat_e)
107
+ sort_e = flat_e[order]
108
+ sort_tok = flat_tok[order]
109
+ xs = x[sort_tok] # (T*k, d)
110
+ counts = torch.bincount(sort_e, minlength=self.n_experts)
111
+ offs = torch.cumsum(counts, 0).to(torch.int32)
112
+ h = _grouped_mm(xs, self.experts.w13, offs) # (T*k, 2f) bf16
113
+ gate, up = h.chunk(2, dim=-1)
114
+ h = F.silu(gate) * up
115
+ y = _grouped_mm(h, self.experts.w2, offs).to(x.dtype) # (T*k, d) -> residual dtype
116
+ y = y * topv.reshape(-1)[order].unsqueeze(-1).to(x.dtype)
117
+ out = torch.zeros_like(x)
118
+ out.index_add_(0, sort_tok, y)
119
+ return out
120
+
121
+ def _experts_bmm(self, x: Tensor, topi: Tensor, topv: Tensor) -> Tensor:
122
+ # reference path: per-expert masked matmul (works on CPU)
123
+ T, d = x.shape
124
+ flat_e = topi.reshape(-1)
125
+ flat_tok = torch.arange(T, device=x.device).repeat_interleave(self.top_k)
126
+ flat_v = topv.reshape(-1)
127
+ out = torch.zeros_like(x)
128
+ for e in range(self.n_experts):
129
+ sel = (flat_e == e).nonzero(as_tuple=True)[0]
130
+ if sel.numel() == 0:
131
+ continue
132
+ toks = flat_tok[sel]
133
+ ye = self.experts.expert_glu(x[toks], e).to(x.dtype) * flat_v[sel].unsqueeze(-1).to(x.dtype)
134
+ out.index_add_(0, toks, ye)
135
+ return out
136
+
137
+ def _shared(self, x: Tensor) -> Tensor:
138
+ out = x.new_zeros(x.shape)
139
+ for e in range(self.n_shared):
140
+ out = out + self.shared.expert_glu(x, e).to(x.dtype)
141
+ return out
142
+
143
+ def forward(self, x: Tensor):
144
+ """x: (B, S, d) -> (out (B,S,d), aux_loss scalar)."""
145
+ B, S, d = x.shape
146
+ xf = x.reshape(-1, d)
147
+ topi, topv, aux = self._route(xf)
148
+ if self.backend == "grouped":
149
+ out = self._experts_grouped(xf, topi, topv)
150
+ else:
151
+ out = self._experts_bmm(xf, topi, topv)
152
+ if self.n_shared > 0:
153
+ out = out + self._shared(xf)
154
+ return out.reshape(B, S, d), aux
hobbylm/multimodal.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal (image + audio) wrapper around the MoE LLM — TinyLLaVA-style.
2
+
3
+ A frozen vision/audio encoder produces patch features; a small MLP projector maps them into the
4
+ LLM's embedding space; the LLM consumes them as ordinary token embeddings spliced at `<image>` /
5
+ `<audio>` sentinel positions. The LLM is unchanged except `forward(inputs_embeds=...)`.
6
+
7
+ Sentinels live in the padded-but-unused GPT-2 vocab slots (50257-50303), so they never collide with
8
+ real tokens and the lm_head already masks them at decode (see generate.py).
9
+
10
+ v1 scope: encoders frozen (features precomputed/cached); projector(s) + (optionally) LLM are trained.
11
+ Batching assumes uniform feature count per modality (fixed-resolution encoders); samples may carry an
12
+ image, audio, both, or neither (text-only) — the merge right-pads, which is safe under causal attention.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ from torch import Tensor
19
+
20
+ from .config import ModelConfig
21
+ from .model import MoETransformer
22
+
23
+ # ---- special tokens in the free vocab slots (vocab 50257 real -> padded 50304) ----
24
+ IMAGE_TOKEN = 50257 # one sentinel per image; replaced by N projected patch features
25
+ AUDIO_TOKEN = 50258 # one sentinel per audio clip
26
+ IM_START = 50259
27
+ IM_END = 50260
28
+ VIDEO_TOKEN = 50261 # video = sampled frames through the SAME vision encoder + mm_projector (no new encoder)
29
+ SPEECH_TOKEN = 50262 # spoken language via a Whisper encoder + speech_projector (distinct from CLAP <audio>)
30
+ IGNORE_INDEX = -1 # target value for non-text positions; matches model CE ignore_index
31
+
32
+
33
+ class Projector(nn.Module):
34
+ """TinyLLaVA `mlp2x_gelu` connector: Linear -> GELU -> Linear, encoder_dim -> d_model."""
35
+ def __init__(self, in_dim: int, out_dim: int):
36
+ super().__init__()
37
+ self.net = nn.Sequential(
38
+ nn.Linear(in_dim, out_dim),
39
+ nn.GELU(),
40
+ nn.Linear(out_dim, out_dim),
41
+ )
42
+
43
+ def forward(self, x: Tensor) -> Tensor:
44
+ return self.net(x)
45
+
46
+
47
+ class MoEVLM(nn.Module):
48
+ """Wraps a MoETransformer with image (and optional audio) projectors.
49
+
50
+ forward inputs:
51
+ input_ids: (B, L) token ids containing IMAGE_TOKEN / AUDIO_TOKEN sentinels.
52
+ image_features: (B, Ni, vision_dim) RAW frozen-encoder features, or None.
53
+ audio_features: (B, Na, audio_dim) RAW frozen-encoder features, or None.
54
+ targets: (B, L) next-token targets aligned to input_ids (sentinels get IGNORE_INDEX), or None.
55
+ """
56
+ def __init__(self, llm: MoETransformer, vision_dim: int = 1152, audio_dim: int | None = None,
57
+ speech_dim: int | None = None):
58
+ super().__init__()
59
+ self.llm = llm
60
+ self.d_model = llm.cfg.d_model
61
+ self.mm_projector = Projector(vision_dim, self.d_model)
62
+ self.audio_projector = Projector(audio_dim, self.d_model) if audio_dim else None
63
+ self.speech_projector = Projector(speech_dim, self.d_model) if speech_dim else None
64
+
65
+ # ---- build the merged (B, L', d) embedding sequence by splicing modality features ----
66
+ def build_inputs_embeds(self, input_ids: Tensor, image_features: Tensor | None = None,
67
+ audio_features: Tensor | None = None, targets: Tensor | None = None,
68
+ video_features: Tensor | None = None, speech_features: Tensor | None = None):
69
+ B, L = input_ids.shape
70
+ dev = input_ids.device
71
+ img_proj = self.mm_projector(image_features) if image_features is not None else None # (B,Ni,d)
72
+ vid_proj = self.mm_projector(video_features) if video_features is not None else None # video reuses mm_projector
73
+ aud_proj = (self.audio_projector(audio_features)
74
+ if (audio_features is not None and self.audio_projector is not None) else None)
75
+ spk_proj = (self.speech_projector(speech_features)
76
+ if (speech_features is not None and self.speech_projector is not None) else None)
77
+
78
+ seqs_e, seqs_t = [], []
79
+ for b in range(B):
80
+ ids = input_ids[b]
81
+ text_emb = self.llm.embed(ids) # (L, d); sentinel rows get sliced out
82
+ # ordered list of (position, feature_block) for every sentinel in this sample
83
+ spots = []
84
+ if img_proj is not None:
85
+ for p in (ids == IMAGE_TOKEN).nonzero(as_tuple=True)[0]:
86
+ spots.append((int(p), img_proj[b]))
87
+ if vid_proj is not None:
88
+ for p in (ids == VIDEO_TOKEN).nonzero(as_tuple=True)[0]:
89
+ spots.append((int(p), vid_proj[b]))
90
+ if aud_proj is not None:
91
+ for p in (ids == AUDIO_TOKEN).nonzero(as_tuple=True)[0]:
92
+ spots.append((int(p), aud_proj[b]))
93
+ if spk_proj is not None:
94
+ for p in (ids == SPEECH_TOKEN).nonzero(as_tuple=True)[0]:
95
+ spots.append((int(p), spk_proj[b]))
96
+ spots.sort(key=lambda s: s[0])
97
+
98
+ e_parts, t_parts, prev = [], [], 0
99
+ for pos, feat in spots:
100
+ e_parts.append(text_emb[prev:pos])
101
+ e_parts.append(feat.to(text_emb.dtype)) # encoder/projector may be bf16 under autocast
102
+ if targets is not None:
103
+ t_parts.append(targets[b][prev:pos])
104
+ # next-token: the LAST feature predicts the token following the sentinel (no internal
105
+ # label shift in our model), so carry targets[pos] onto it; the rest are ignored.
106
+ ft = torch.full((feat.shape[0],), IGNORE_INDEX, dtype=targets.dtype, device=dev)
107
+ ft[-1] = targets[b][pos]
108
+ t_parts.append(ft)
109
+ prev = pos + 1
110
+ e_parts.append(text_emb[prev:])
111
+ if targets is not None:
112
+ t_parts.append(targets[b][prev:])
113
+ seqs_e.append(torch.cat(e_parts, dim=0))
114
+ if targets is not None:
115
+ seqs_t.append(torch.cat(t_parts, dim=0))
116
+
117
+ # right-pad to the longest merged sequence (causal attention -> pads don't affect real tokens)
118
+ Lmax = max(e.shape[0] for e in seqs_e)
119
+ inputs_embeds = seqs_e[0].new_zeros(B, Lmax, self.d_model)
120
+ new_targets = None
121
+ if targets is not None:
122
+ new_targets = torch.full((B, Lmax), IGNORE_INDEX, dtype=targets.dtype, device=dev)
123
+ for b, e in enumerate(seqs_e):
124
+ inputs_embeds[b, :e.shape[0]] = e
125
+ if targets is not None:
126
+ new_targets[b, :seqs_t[b].shape[0]] = seqs_t[b]
127
+ return inputs_embeds, new_targets
128
+
129
+ def forward(self, input_ids: Tensor, image_features: Tensor | None = None,
130
+ audio_features: Tensor | None = None, targets: Tensor | None = None,
131
+ video_features: Tensor | None = None, speech_features: Tensor | None = None):
132
+ inputs_embeds, new_targets = self.build_inputs_embeds(
133
+ input_ids, image_features, audio_features, targets, video_features, speech_features)
134
+ return self.llm(inputs_embeds=inputs_embeds, targets=new_targets)
135
+
136
+ # ---- param groups: freeze encoders (external), optionally freeze the LLM (stage 1) ----
137
+ def set_llm_trainable(self, trainable: bool):
138
+ for p in self.llm.parameters():
139
+ p.requires_grad = trainable
140
+
141
+ def projector_parameters(self):
142
+ ps = list(self.mm_projector.parameters())
143
+ if self.audio_projector is not None:
144
+ ps += list(self.audio_projector.parameters())
145
+ if self.speech_projector is not None:
146
+ ps += list(self.speech_projector.parameters())
147
+ return ps
148
+
149
+
150
+ def build_vlm(cfg: ModelConfig, vision_dim: int = 1152, audio_dim: int | None = None) -> MoEVLM:
151
+ return MoEVLM(MoETransformer(cfg), vision_dim=vision_dim, audio_dim=audio_dim)
hobbylm/vision.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen SigLIP2 vision encoder wrapper for the MoE-VLM.
2
+
3
+ Loads `google/siglip2-so400m-patch14-384` (or any SigLIP/SigLIP2), runs images through its vision
4
+ tower under no_grad, and returns patch features (B, N, hidden) to be projected + spliced by MoEVLM.
5
+ The encoder is frozen in every training stage, so we run it on the fly (precomputing features for
6
+ 558K images would be ~900 GB). Lazy transformers import so the module is CPU-importable.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+ SIGLIP2_ID = "google/siglip2-so400m-patch14-384"
14
+
15
+
16
+ class SiglipVision(nn.Module):
17
+ def __init__(self, model_id: str = SIGLIP2_ID, device="cuda", dtype=torch.bfloat16):
18
+ super().__init__()
19
+ from transformers import AutoModel, AutoProcessor
20
+ self.processor = AutoProcessor.from_pretrained(model_id)
21
+ full = AutoModel.from_pretrained(model_id, torch_dtype=dtype)
22
+ self.vision = full.vision_model.to(device).eval()
23
+ for p in self.vision.parameters():
24
+ p.requires_grad = False
25
+ self.device = device
26
+ self.dtype = dtype
27
+ self.hidden = self.vision.config.hidden_size
28
+
29
+ @torch.no_grad()
30
+ def preprocess(self, images) -> torch.Tensor:
31
+ """images: list of PIL.Image -> pixel_values (B, 3, H, W) on device."""
32
+ px = self.processor(images=images, return_tensors="pt").pixel_values
33
+ return px.to(self.device, self.dtype)
34
+
35
+ @torch.no_grad()
36
+ def encode_pixels(self, pixel_values: torch.Tensor) -> torch.Tensor:
37
+ """pixel_values (B,3,H,W) -> patch features (B, N, hidden)."""
38
+ return self.vision(pixel_values=pixel_values).last_hidden_state
39
+
40
+ @torch.no_grad()
41
+ def encode(self, images) -> torch.Tensor:
42
+ """list of PIL.Image -> (B, N, hidden) patch features."""
43
+ return self.encode_pixels(self.preprocess(images))
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch
2
+ gradio>=4.44
3
+ transformers>=4.49
4
+ diffusers==0.32.2
5
+ accelerate
6
+ safetensors
7
+ tiktoken
8
+ huggingface_hub>=0.25
9
+ pillow
10
+ numpy
11
+ sentencepiece