iamrahulreddy commited on
Commit
d8f717b
·
verified ·
1 Parent(s): 4515975

add: sparse_moe architecture source

Browse files
architecture/sparse_moe/__init__.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .config import (
2
+ ExpertConfig,
3
+ RouterConfig,
4
+ TrainingConfig,
5
+ DataConfig,
6
+ BenchmarkConfig,
7
+ ProjectConfig,
8
+ load_preset,
9
+ )
10
+ from .experts import LoRAAdapter, TinyExpert, create_experts
11
+ from .routing import LinearRouter, RoutingOutput
12
+ from .layers import SparseMoELayer
13
+ from .injection import inject_moe_layers
14
+ from .trainer import MoETrainer
15
+ from .evaluation import (
16
+ evaluate_model,
17
+ evaluate_per_domain,
18
+ parameter_summary,
19
+ print_domain_report,
20
+ print_report,
21
+ )
22
+ from .analysis import compute_specialization_matrix, print_specialization_report
23
+ from .datasets import (
24
+ DictDataset,
25
+ load_domain_texts,
26
+ split_and_build_loaders,
27
+ build_mixed_loader,
28
+ tokenize_and_batch,
29
+ )
30
+ from .utils import build_labels, set_seed, to_serializable, parse_seed_list
31
+ from .baselines import (
32
+ prepare_dense_lora_model,
33
+ train_dense_lora_model,
34
+ train_dense_lora_baseline,
35
+ )
36
+ from .reporting import (
37
+ save_dashboard,
38
+ generate_qualitative_samples,
39
+ build_before_after_rows,
40
+ print_before_after_report,
41
+ run_multi_seed,
42
+ )
43
+ from .visualization import (
44
+ ColorPalette,
45
+ BasePlot,
46
+ DomainScorePlot,
47
+ TrainingConvergencePlot,
48
+ ExpertRoutingHeatmap,
49
+ )
50
+
51
+ __all__ = [
52
+ # Config
53
+ "ExpertConfig",
54
+ "RouterConfig",
55
+ "TrainingConfig",
56
+ "DataConfig",
57
+ "BenchmarkConfig",
58
+
59
+ "ProjectConfig",
60
+ "load_preset",
61
+ # Experts
62
+ "LoRAAdapter",
63
+ "TinyExpert",
64
+ "create_experts",
65
+ # Router
66
+ "LinearRouter",
67
+ "RoutingOutput",
68
+ # MoE layer
69
+ "SparseMoELayer",
70
+ # Injection
71
+ "inject_moe_layers",
72
+ # Training
73
+ "MoETrainer",
74
+ # Evaluation
75
+ "evaluate_model",
76
+ "evaluate_per_domain",
77
+
78
+ "parameter_summary",
79
+ "print_domain_report",
80
+ "print_report",
81
+ # Analysis
82
+ "compute_specialization_matrix",
83
+ "print_specialization_report",
84
+ # Data
85
+ "DictDataset",
86
+ "load_domain_texts",
87
+ "split_and_build_loaders",
88
+ "build_mixed_loader",
89
+ "tokenize_and_batch",
90
+ # Utils
91
+ "build_labels",
92
+ "set_seed",
93
+ "to_serializable",
94
+ "parse_seed_list",
95
+ # Dense baseline
96
+ "prepare_dense_lora_model",
97
+ "train_dense_lora_model",
98
+ "train_dense_lora_baseline",
99
+ # Reporting
100
+ "save_dashboard",
101
+ "generate_qualitative_samples",
102
+ "build_before_after_rows",
103
+ "print_before_after_report",
104
+ "run_multi_seed",
105
+ # Visualization
106
+ "ColorPalette",
107
+ "BasePlot",
108
+ "DomainScorePlot",
109
+ "TrainingConvergencePlot",
110
+ "ExpertRoutingHeatmap",
111
+ ]
112
+
113
+ __version__ = "0.1.0"
architecture/sparse_moe/analysis.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Optional
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ from torch.utils.data import DataLoader
8
+
9
+ from .evaluation import _named_moe_layers
10
+ from .utils import build_labels
11
+
12
+
13
+ # Core: expert–domain affinity matrix
14
+ @torch.no_grad()
15
+ def compute_specialization_matrix(model: nn.Module, domain_loaders: Dict[str, DataLoader], device: Optional[torch.device] = None) -> Dict[str, object]:
16
+ device = device or next(model.parameters()).device
17
+ was_training = model.training
18
+ model.eval()
19
+
20
+ try:
21
+ moe_layers = _named_moe_layers(model)
22
+ if not moe_layers:
23
+ raise ValueError("No SparseMoELayer found in model")
24
+ domains = list(domain_loaders.keys())
25
+ num_domains = len(domains)
26
+
27
+ # Accumulate weighted expert fractions per domain, keeping layers separate.
28
+ layer_affinity = {
29
+ layer_name: torch.zeros(layer.num_experts, num_domains)
30
+ for layer_name, layer in moe_layers
31
+ }
32
+ layer_weight_per_domain = {
33
+ layer_name: torch.zeros(num_domains) for layer_name, _ in moe_layers
34
+ }
35
+
36
+ for d_idx, domain in enumerate(domains):
37
+ loader = domain_loaders[domain]
38
+ for batch in loader:
39
+ batch = {k: v.to(device) for k, v in batch.items()}
40
+ labels = build_labels(batch)
41
+ _ = model(**batch, labels=labels)
42
+
43
+ for layer_name, module in moe_layers:
44
+ stats = getattr(module, "_last_stats", None)
45
+ if stats is None:
46
+ continue
47
+ fracs = stats.get("expert_fractions", [])
48
+ if len(fracs) != module.num_experts:
49
+ continue
50
+
51
+ num_tokens = float(stats.get("num_tokens", 1))
52
+ frac_t = torch.tensor(fracs, dtype=torch.float32)
53
+
54
+ layer_affinity[layer_name][:, d_idx] += frac_t * num_tokens
55
+ layer_weight_per_domain[layer_name][d_idx] += num_tokens
56
+
57
+ # Normalise and flatten rows as unique layer-expert slots.
58
+ affinity_rows = []
59
+ expert_labels = []
60
+ for layer_name, module in moe_layers:
61
+ layer_matrix = layer_affinity[layer_name]
62
+ for d_idx in range(num_domains):
63
+ weight = layer_weight_per_domain[layer_name][d_idx]
64
+ if weight > 0:
65
+ layer_matrix[:, d_idx] /= weight
66
+ affinity_rows.append(layer_matrix)
67
+ for expert_idx in range(module.num_experts):
68
+ expert_labels.append(f"{layer_name}:E{expert_idx}")
69
+
70
+ affinity = torch.cat(affinity_rows, dim=0) if affinity_rows else torch.zeros(0, num_domains)
71
+
72
+ # Specialisation
73
+ row_max = affinity.max(dim=1).values
74
+ row_mean = affinity.mean(dim=1).clamp(min=1e-9)
75
+ specialisation = row_max / row_mean
76
+
77
+ # JS divergence
78
+ col_sums = affinity.sum(dim=0, keepdim=True).clamp(min=1e-9)
79
+ col_norm = affinity / col_sums
80
+ divergence = _pairwise_js(col_norm)
81
+
82
+ return {
83
+ "affinity": affinity,
84
+ "layer_affinity": layer_affinity,
85
+ "domains": domains,
86
+ "expert_labels": expert_labels,
87
+ "specialisation": specialisation,
88
+ "divergence": divergence,
89
+ }
90
+ finally:
91
+ model.train(was_training)
92
+
93
+
94
+ # Helpers
95
+ def _kl_divergence(P: torch.Tensor, q: torch.Tensor) -> torch.Tensor:
96
+ P_safe = P.clamp(min=1e-9)
97
+ q_safe = q.clamp(min=1e-9)
98
+ return (P_safe * (P_safe / q_safe).log()).sum()
99
+
100
+
101
+ def _pairwise_js(col_norm: torch.Tensor) -> torch.Tensor:
102
+ D = col_norm.shape[1]
103
+ js = torch.zeros(D, D)
104
+
105
+ for i in range(D):
106
+ for j in range(i + 1, D):
107
+ p = col_norm[:, i]
108
+ q = col_norm[:, j]
109
+ m = 0.5 * (p + q)
110
+ jsd = 0.5 * _kl_divergence(p, m) + 0.5 * _kl_divergence(q, m)
111
+ js[i, j] = js[j, i] = jsd.item()
112
+
113
+ return js
114
+
115
+
116
+ # Pretty-print
117
+ def print_specialization_report(result: Dict[str, object]):
118
+ affinity = result["affinity"]
119
+ domains = result["domains"]
120
+ expert_labels = result.get("expert_labels", [f"E{i}" for i in range(affinity.shape[0])])
121
+ spec = result["specialisation"]
122
+ div = result["divergence"]
123
+
124
+ E, D = affinity.shape
125
+
126
+ print("\n" + "=" * 62)
127
+ print(" Expert Specialisation Report")
128
+ print("=" * 62)
129
+
130
+ # Affinity matrix
131
+ header = " Layer-Expert " + "".join(f" {d:>8s}" for d in domains) + " Spec"
132
+ print(header)
133
+ print(" " + "-" * (len(header) - 2))
134
+ for e in range(E):
135
+ row = f" {expert_labels[e]:<28s}"
136
+ for d in range(D):
137
+ row += f" {affinity[e, d]:>8.3f}"
138
+ row += f" {spec[e]:>5.2f}"
139
+ print(row)
140
+
141
+ # Divergence
142
+ print(f"\n JS Divergence (domain routing distances):")
143
+ div_header = " " + "".join(f" {d:>8s}" for d in domains)
144
+ print(div_header)
145
+ for i, d_name in enumerate(domains):
146
+ row = f" {d_name:<8s}"
147
+ for j in range(D):
148
+ row += f" {div[i, j]:>8.4f}"
149
+ print(row)
150
+
151
+ print("=" * 62 + "\n")
architecture/sparse_moe/baselines.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import math
5
+ from typing import Dict, List, Optional
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ from torch.utils.data import DataLoader
10
+ from transformers import AutoModelForCausalLM
11
+
12
+ from .config import ExpertConfig, RouterConfig, TrainingConfig
13
+ from .evaluation import evaluate_per_domain
14
+ from .experts import LoRAAdapter
15
+ from .injection import _is_ffn
16
+ from .trainer import _cosine_with_warmup, _build_amp
17
+ from .utils import build_labels
18
+
19
+
20
+
21
+ # LoRA injection (dense — no routing, no MoE)
22
+ def _inject_dense_lora(model: nn.Module, expert_config: ExpertConfig, router_config: RouterConfig) -> nn.Module:
23
+ model_type = getattr(model.config, "model_type", "qwen2")
24
+
25
+ # Scale rank to match MoE
26
+ scaled_rank = expert_config.lora_rank * router_config.num_experts
27
+
28
+ # Freeze all base params
29
+ for p in model.parameters():
30
+ p.requires_grad = False
31
+
32
+ replaced = 0
33
+ for name, module in list(model.named_modules()):
34
+ if not _is_ffn(module, model_type):
35
+ continue
36
+
37
+ # Get input dimension (d_model) for the LoRA adapter
38
+ d_model = getattr(model.config, "hidden_size", None)
39
+ if d_model is None:
40
+ # Fallback heuristic if config lacks hidden_size
41
+ for child in module.children():
42
+ if hasattr(child, "in_features"):
43
+ d_model = child.in_features
44
+ break
45
+ elif hasattr(child, "weight"):
46
+ d_model = child.weight.size(-1)
47
+ break
48
+
49
+ if d_model is None:
50
+ print(f"[WARNING] Could not determine d_model for {name}, skipping.")
51
+ continue
52
+
53
+ # Create a single LoRA adapter with scaled rank
54
+ mod_config = copy.copy(expert_config)
55
+ mod_config.lora_rank = min(scaled_rank, 64, d_model // 2)
56
+ mod_config.lora_alpha = expert_config.lora_alpha * (mod_config.lora_rank / expert_config.lora_rank)
57
+
58
+ # Resolve the precise device and dtype of the layer being replaced
59
+ try:
60
+ layer_device = next(module.parameters()).device
61
+ layer_dtype = next(module.parameters()).dtype
62
+ except StopIteration:
63
+ layer_device = next(model.parameters()).device
64
+ layer_dtype = next(model.parameters()).dtype
65
+
66
+ lora = LoRAAdapter(
67
+ d_model=d_model,
68
+ config=mod_config
69
+ ).to(device=layer_device, dtype=layer_dtype)
70
+
71
+ # Wrap FFN + LoRA
72
+ wrapper = _DenseLoRAWrapper(module, lora)
73
+
74
+ # Replace in parent
75
+ parts = name.split(".")
76
+ parent = model
77
+ for part in parts[:-1]:
78
+ parent = getattr(parent, part)
79
+ setattr(parent, parts[-1], wrapper)
80
+ replaced += 1
81
+
82
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
83
+ if replaced > 0:
84
+ print(
85
+ f"Dense LoRA: injected {replaced} adapters, "
86
+ f"rank={mod_config.lora_rank}, "
87
+ f"trainable={trainable:,} params"
88
+ )
89
+ else:
90
+ raise ValueError(
91
+ "Dense LoRA: no FFN layers found; model was not modified. "
92
+ "Check architecture support."
93
+ )
94
+ return model
95
+
96
+
97
+ class _DenseLoRAWrapper(nn.Module):
98
+
99
+ def __init__(self, base_ffn: nn.Module, lora: LoRAAdapter):
100
+ super().__init__()
101
+ self.base_ffn = base_ffn
102
+ self.lora = lora
103
+
104
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
105
+ # saves memory
106
+ with torch.no_grad():
107
+ base_out = self.base_ffn(x)
108
+ lora_out = self.lora(x)
109
+ return base_out + lora_out.to(base_out.dtype)
110
+
111
+
112
+ # Training loop (simplified — no routing, no aux loss)
113
+ def _train_dense(model: nn.Module, train_loader: DataLoader, val_loader: Optional[DataLoader], config: TrainingConfig, device: torch.device) -> Dict[str, List[float]]:
114
+ model.train()
115
+
116
+ if device.type == "cuda":
117
+ torch.set_float32_matmul_precision("high")
118
+ torch.backends.cudnn.benchmark = True
119
+
120
+ trainable = [p for p in model.parameters() if p.requires_grad]
121
+ if not trainable:
122
+ raise ValueError(
123
+ "Dense LoRA baseline has no trainable parameters. "
124
+ "Check FFN detection for this architecture."
125
+ )
126
+ dense_lr = config.lr / 2.0
127
+ optimizer = torch.optim.AdamW(trainable, lr=dense_lr, weight_decay=0.01)
128
+
129
+ steps_per_epoch = max(
130
+ math.ceil(len(train_loader) / config.gradient_accumulation_steps),
131
+ 1,
132
+ )
133
+ total_steps = steps_per_epoch * config.num_epochs
134
+ scheduler = torch.optim.lr_scheduler.LambdaLR(
135
+ optimizer,
136
+ lr_lambda=lambda step: _cosine_with_warmup(
137
+ step, config.warmup_steps, total_steps
138
+ ),
139
+ )
140
+
141
+ use_amp, amp_dtype, scaler = _build_amp(device, config.fp16)
142
+
143
+ history = {"train_loss": [], "val_loss": [], "lr": [], "epoch_boundaries": []}
144
+ accum = config.gradient_accumulation_steps
145
+ global_step = 0
146
+ best_val_loss = float("inf")
147
+ patience = 1
148
+ patience_counter = 0
149
+ best_state = None
150
+
151
+ for epoch in range(1, config.num_epochs + 1):
152
+ print(f" [Dense LoRA] epoch {epoch}/{config.num_epochs}")
153
+ history["epoch_boundaries"].append(global_step)
154
+ optimizer.zero_grad(set_to_none=True)
155
+
156
+ epoch_loss = 0.0
157
+ epoch_steps = 0
158
+
159
+ for step, batch in enumerate(train_loader):
160
+ batch = {k: v.to(device, non_blocking=True) for k, v in batch.items()}
161
+ labels = build_labels(batch)
162
+
163
+ with torch.amp.autocast(device.type, dtype=amp_dtype, enabled=use_amp):
164
+ outputs = model(**batch, labels=labels)
165
+ loss = outputs.loss / accum
166
+
167
+ scaler.scale(loss).backward()
168
+
169
+ if (step + 1) % accum == 0 or (step + 1) == len(train_loader):
170
+ scaler.unscale_(optimizer)
171
+ nn.utils.clip_grad_norm_(trainable, config.max_grad_norm)
172
+ scaler.step(optimizer)
173
+ scaler.update()
174
+ scheduler.step()
175
+ optimizer.zero_grad(set_to_none=True)
176
+ global_step += 1
177
+
178
+ raw_loss = loss.item() * accum
179
+ current_lr = scheduler.get_last_lr()[0]
180
+ history["train_loss"].append(raw_loss)
181
+ history["lr"].append(current_lr)
182
+ epoch_loss += raw_loss
183
+ epoch_steps += 1
184
+
185
+ if global_step % config.log_every_steps == 0:
186
+ print(
187
+ f" [Dense LoRA] step {global_step:>5d} | "
188
+ f"loss={raw_loss:.4f} | "
189
+ f"lr={current_lr:.2e} | "
190
+ f"avg={epoch_loss / max(epoch_steps, 1):.4f}"
191
+ )
192
+
193
+ if val_loader is None:
194
+ continue
195
+
196
+ val_loss = _evaluate_dense(model, val_loader, device, use_amp, amp_dtype)
197
+ history["val_loss"].append(val_loss)
198
+ print(f" [Dense LoRA] val_loss={val_loss:.4f}")
199
+
200
+ if val_loss < best_val_loss:
201
+ best_val_loss = val_loss
202
+ patience_counter = 0
203
+ best_state = {
204
+ name: param.detach().clone()
205
+ for name, param in model.named_parameters()
206
+ if param.requires_grad
207
+ }
208
+ else:
209
+ patience_counter += 1
210
+ if patience_counter >= patience:
211
+ print(" [Dense LoRA] early stopping triggered; restoring best weights.")
212
+ if best_state is not None:
213
+ for name, param in model.named_parameters():
214
+ if name in best_state:
215
+ param.data.copy_(best_state[name])
216
+ break
217
+
218
+ print(
219
+ f"Dense LoRA training complete ({global_step} steps across up to {config.num_epochs} epochs)."
220
+ )
221
+ return history
222
+
223
+
224
+ @torch.no_grad()
225
+ def _evaluate_dense(model: nn.Module, val_loader: DataLoader, device: torch.device, use_amp: bool, amp_dtype: torch.dtype) -> float:
226
+ model.eval()
227
+ total_loss, total_tokens = 0.0, 0
228
+ for batch in val_loader:
229
+ batch = {k: v.to(device, non_blocking=True) for k, v in batch.items()}
230
+ labels = build_labels(batch)
231
+ with torch.amp.autocast(device.type, dtype=amp_dtype, enabled=use_amp):
232
+ outputs = model(**batch, labels=labels)
233
+ valid_tokens = int((labels[:, 1:] != -100).sum().item())
234
+ total_loss += outputs.loss.item() * max(valid_tokens, 1)
235
+ total_tokens += valid_tokens
236
+ model.train()
237
+ return total_loss / max(total_tokens, 1)
238
+
239
+
240
+ # Public API
241
+ def prepare_dense_lora_model(base_model_name: str, expert_config: ExpertConfig, router_config: RouterConfig, device: torch.device) -> nn.Module:
242
+ model = AutoModelForCausalLM.from_pretrained(base_model_name).to(device)
243
+ model = _inject_dense_lora(model, expert_config, router_config)
244
+
245
+ try:
246
+ model.gradient_checkpointing_enable(
247
+ gradient_checkpointing_kwargs={"use_reentrant": False}
248
+ )
249
+ except TypeError:
250
+ model.gradient_checkpointing_enable()
251
+
252
+ return model
253
+
254
+
255
+ def train_dense_lora_model(model: nn.Module, train_loader: DataLoader, val_loader: Optional[DataLoader], training_config: TrainingConfig, device: torch.device) -> Dict[str, List[float]]:
256
+ return _train_dense(model, train_loader, val_loader, training_config, device)
257
+
258
+
259
+ def train_dense_lora_baseline(base_model_name: str, train_loader: DataLoader, eval_loaders: Dict[str, DataLoader], training_config: TrainingConfig, expert_config: ExpertConfig, router_config: RouterConfig, device: torch.device, val_loader: Optional[DataLoader] = None) -> Dict[str, Dict]:
260
+ print("═══ Dense LoRA Baseline ═══")
261
+
262
+ # Load fresh model (don't contaminate the MoE model)
263
+ model = prepare_dense_lora_model(
264
+ base_model_name,
265
+ expert_config,
266
+ router_config,
267
+ device,
268
+ )
269
+
270
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
271
+
272
+ dense_config = copy.deepcopy(training_config)
273
+
274
+ # Train with the same epoch schedule as the main MoE path.
275
+ history = train_dense_lora_model(model, train_loader, val_loader, dense_config, device)
276
+
277
+ # Evaluate per-domain
278
+ print("Evaluating Dense LoRA per-domain...")
279
+ per_domain = evaluate_per_domain(model, eval_loaders, device)
280
+
281
+ # Cleanup
282
+ del model
283
+ if torch.cuda.is_available():
284
+ torch.cuda.empty_cache()
285
+
286
+ return {
287
+ "per_domain": per_domain,
288
+ "history": history,
289
+ "trainable_params": trainable,
290
+ }
architecture/sparse_moe/config.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, fields
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import torch
7
+
8
+
9
+ # Expert configuration
10
+ @dataclass
11
+ class ExpertConfig:
12
+
13
+ expert_type: str = "lora"
14
+
15
+ # ── LoRA settings ──
16
+ lora_rank: int = 16
17
+ lora_alpha: float = 32.0
18
+ lora_dropout: float = 0.05
19
+
20
+ # ── TinyExpert settings ──
21
+ tiny_intermediate: Optional[int] = None
22
+ tiny_activation: str = "silu"
23
+ tiny_dropout: float = 0.1
24
+
25
+ def __post_init__(self):
26
+ if self.expert_type not in ("lora", "tiny"):
27
+ raise ValueError(
28
+ f"expert_type must be 'lora' or 'tiny', got {self.expert_type!r}"
29
+ )
30
+ if self.lora_rank <= 0:
31
+ raise ValueError(f"lora_rank must be positive, got {self.lora_rank}")
32
+ if self.lora_alpha <= 0:
33
+ raise ValueError(f"lora_alpha must be positive, got {self.lora_alpha}")
34
+ if not (0 <= self.lora_dropout < 1):
35
+ raise ValueError(f"lora_dropout must be in [0, 1), got {self.lora_dropout}")
36
+ if self.tiny_intermediate is not None and self.tiny_intermediate <= 0:
37
+ raise ValueError(
38
+ f"tiny_intermediate must be positive, got {self.tiny_intermediate}"
39
+ )
40
+ if self.tiny_activation not in ("gelu", "silu", "relu"):
41
+ raise ValueError(
42
+ f"tiny_activation must be gelu/silu/relu, got {self.tiny_activation!r}"
43
+ )
44
+ if not (0 <= self.tiny_dropout < 1):
45
+ raise ValueError(
46
+ f"tiny_dropout must be in [0, 1), got {self.tiny_dropout}"
47
+ )
48
+
49
+
50
+ # Router configuration
51
+ @dataclass
52
+ class RouterConfig:
53
+
54
+ num_experts: int = 8
55
+ top_k: int = 2
56
+ noise_std: float = 0.1
57
+
58
+ z_loss_coeff: float = 1e-4
59
+
60
+ loss_type: str = "switch"
61
+
62
+ entropy_reg: float = 0.01
63
+
64
+ def __post_init__(self):
65
+ if self.num_experts <= 0:
66
+ raise ValueError(f"num_experts must be positive, got {self.num_experts}")
67
+ if self.top_k <= 0:
68
+ raise ValueError(f"top_k must be positive, got {self.top_k}")
69
+ if self.top_k > self.num_experts:
70
+ raise ValueError(
71
+ f"top_k ({self.top_k}) cannot exceed num_experts ({self.num_experts})"
72
+ )
73
+ if self.noise_std < 0:
74
+ raise ValueError(f"noise_std must be >= 0, got {self.noise_std}")
75
+ if self.loss_type not in ("switch", "gshard"):
76
+ raise ValueError(
77
+ f"loss_type must be 'switch' or 'gshard', got {self.loss_type!r}"
78
+ )
79
+
80
+
81
+ # Training configuration
82
+ @dataclass
83
+ class TrainingConfig:
84
+
85
+ num_epochs: int = 8
86
+ lr: float = 2e-4
87
+ batch_size: int = 8
88
+ gradient_accumulation_steps: int = 4
89
+
90
+ max_seq_len: int = 512
91
+ max_grad_norm: float = 1.0
92
+ warmup_steps: int = 100
93
+ fp16: bool = True
94
+
95
+ entropy_reg_start: float = 0.01
96
+
97
+ aux_loss_weight: float = 0.01
98
+
99
+ output_dir: str = "checkpoints"
100
+ save_every_steps: int = 500
101
+ log_every_steps: int = 20
102
+
103
+
104
+ # Data configuration
105
+ @dataclass
106
+ class DataConfig:
107
+
108
+ samples_per_domain: int = 3000
109
+ eval_frac: float = 0.15
110
+ domains: List[str] = field(default_factory=lambda: ["code", "prose", "qa", "math"])
111
+
112
+
113
+ # Benchmark configuration
114
+ @dataclass
115
+ class BenchmarkConfig:
116
+
117
+ num_warmup_runs: int = 3
118
+ num_timed_runs: int = 10
119
+ max_gen_tokens: int = 80
120
+ report_dir: str = "benchmark/report"
121
+
122
+
123
+ # Top-level project configuration
124
+ @dataclass
125
+ class ProjectConfig:
126
+
127
+ base_model: str = "Qwen/Qwen2.5-3B"
128
+ seed: int = 42
129
+ device: str = "auto"
130
+
131
+ expert: ExpertConfig = field(default_factory=ExpertConfig)
132
+ routing: RouterConfig = field(default_factory=RouterConfig)
133
+ training: TrainingConfig = field(default_factory=TrainingConfig)
134
+ data: DataConfig = field(default_factory=DataConfig)
135
+ benchmark: BenchmarkConfig = field(default_factory=BenchmarkConfig)
136
+
137
+ # ── Demo / experiment flags ──
138
+ artifact_dir: str = "artifacts"
139
+ run_dense_baseline: bool = True
140
+ run_multi_seed: bool = False
141
+ multi_seed_list: str = "42,43,44"
142
+ multi_seed_epochs: int = 2
143
+
144
+ # ── Pipeline / Resume flags ──
145
+ resume: bool = False
146
+ stage: str = "stage_03_moe_training"
147
+ hub_repo_id: Optional[str] = None
148
+
149
+ def __post_init__(self):
150
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
151
+ # Disable FP16 on CPU
152
+ if self.device == "cpu":
153
+ self.training.fp16 = False
154
+
155
+ @property
156
+ def resolved_device(self) -> torch.device:
157
+ return torch.device(self.device)
158
+
159
+ # YAML loading
160
+ @classmethod
161
+ def from_yaml(cls, path: str) -> "ProjectConfig":
162
+ try:
163
+ import yaml
164
+ except ImportError:
165
+ raise ImportError("PyYAML required for YAML config loading: pip install pyyaml")
166
+
167
+ with open(path, "r") as f:
168
+ raw = yaml.safe_load(f) or {}
169
+
170
+ return cls._from_dict(raw)
171
+
172
+ @classmethod
173
+ def _from_dict(cls, d: Dict[str, Any]) -> "ProjectConfig":
174
+ sub_configs = {
175
+ "expert": ExpertConfig,
176
+ "routing": RouterConfig,
177
+ "training": TrainingConfig,
178
+ "data": DataConfig,
179
+ "benchmark": BenchmarkConfig,
180
+ }
181
+
182
+ kwargs: Dict[str, Any] = {}
183
+ for key, value in d.items():
184
+ if key in sub_configs and isinstance(value, dict):
185
+ # Build nested dataclass with type coercion
186
+ kwargs[key] = _coerce_dataclass(sub_configs[key], value)
187
+ else:
188
+ kwargs[key] = value
189
+
190
+ return _coerce_dataclass(cls, kwargs)
191
+
192
+ def to_dict(self) -> Dict[str, Any]:
193
+ return _asdict_recursive(self)
194
+
195
+
196
+ # Type coercion helper
197
+ def _coerce_dataclass(cls, kwargs: Dict[str, Any]):
198
+ import logging
199
+ _log = logging.getLogger(__name__)
200
+
201
+ def _coerce_bool(value: Any) -> bool:
202
+ if isinstance(value, str):
203
+ lowered = value.strip().lower()
204
+ if lowered in {"1", "true", "yes", "on"}:
205
+ return True
206
+ if lowered in {"0", "false", "no", "off"}:
207
+ return False
208
+ raise ValueError(f"Cannot parse boolean value from {value!r}")
209
+ return bool(value)
210
+
211
+ _str_to_type = {"int": int, "float": float, "bool": _coerce_bool, "str": str}
212
+ field_types = {f.name: f.type for f in fields(cls)}
213
+ coerced = {}
214
+ for key, value in kwargs.items():
215
+ # Filter out unknown keys with a warning (catches YAML typos)
216
+ if key not in field_types:
217
+ _log.warning(
218
+ f"Ignoring unknown config key {key!r} for {cls.__name__}. "
219
+ f"Valid keys: {list(field_types.keys())}"
220
+ )
221
+ continue
222
+
223
+ type_str = field_types[key]
224
+ cast_fn = _str_to_type.get(type_str)
225
+
226
+ # Handle Optional[T] annotations (rendered as 'Optional[T]' strings)
227
+ if cast_fn is None and isinstance(type_str, str) and "Optional" in type_str:
228
+ for base_name, base_fn in _str_to_type.items():
229
+ if base_name in type_str:
230
+ cast_fn = base_fn
231
+ break
232
+
233
+ if cast_fn is not None and not isinstance(value, type(None)):
234
+ try:
235
+ value = cast_fn(value)
236
+ except (ValueError, TypeError) as e:
237
+ _log.warning(
238
+ f"Could not coerce {key!r}={value!r} to {type_str}: {e}"
239
+ )
240
+ coerced[key] = value
241
+
242
+ return cls(**coerced)
243
+
244
+
245
+ # Scale presets
246
+ PRESETS: Dict[str, Dict[str, Any]] = {
247
+ # 'test' is maintained for the unit test suite gate.
248
+ "test": {
249
+ "base_model": "Qwen/Qwen2.5-3B",
250
+ "expert": {"lora_rank": 16, "lora_alpha": 32.0, "lora_dropout": 0.1},
251
+ "routing": {"num_experts": 16, "top_k": 2},
252
+ "training": {
253
+ "num_epochs": 1,
254
+ "lr": 5e-5,
255
+ "batch_size": 2,
256
+ "max_seq_len": 512,
257
+ "gradient_accumulation_steps": 1,
258
+ "fp16": True,
259
+ "warmup_steps": 5,
260
+ "save_every_steps": 0,
261
+ "log_every_steps": 1,
262
+ "aux_loss_weight": 0.05,
263
+ },
264
+ "data": {"samples_per_domain": 50, "eval_frac": 0.1},
265
+ "benchmark": {"num_warmup_runs": 1, "num_timed_runs": 1, "max_gen_tokens": 10},
266
+ "run_dense_baseline": False,
267
+ "run_multi_seed": False,
268
+ },
269
+
270
+ "production": {
271
+ "base_model": "Qwen/Qwen2.5-3B",
272
+ "expert": {"lora_rank": 16, "lora_alpha": 32.0, "lora_dropout": 0.1},
273
+ "routing": {"num_experts": 16, "top_k": 2},
274
+ "training": {
275
+ "num_epochs": 4,
276
+ "lr": 5e-5, # Conservative for 3B — see RESULTS_OVER_SESSIONS.txt
277
+ "batch_size": 24,
278
+ "max_seq_len": 4096,
279
+ "gradient_accumulation_steps": 2,
280
+ "fp16": True,
281
+ "warmup_steps": 350,
282
+ "save_every_steps": 500,
283
+ "log_every_steps": 25,
284
+ "aux_loss_weight": 0.05,
285
+ },
286
+ "data": {"samples_per_domain": 15000, "eval_frac": 0.1},
287
+ "benchmark": {"num_timed_runs": 20, "max_gen_tokens": 100},
288
+ "run_dense_baseline": True,
289
+ "run_multi_seed": True,
290
+ "multi_seed_epochs": 3,
291
+ },
292
+ "cloud_validation": {
293
+ "base_model": "Qwen/Qwen2.5-3B",
294
+ "expert": {"lora_rank": 16, "lora_alpha": 32.0, "lora_dropout": 0.1},
295
+ "routing": {"num_experts": 16, "top_k": 2},
296
+ "training": {
297
+ "num_epochs": 1,
298
+ "lr": 5e-5,
299
+ "batch_size": 8,
300
+ "max_seq_len": 2048,
301
+ "gradient_accumulation_steps": 2,
302
+ "fp16": True,
303
+ "warmup_steps": 50,
304
+ "save_every_steps": 100,
305
+ "log_every_steps": 5,
306
+ "aux_loss_weight": 0.05,
307
+ },
308
+ "data": {"samples_per_domain": 500, "eval_frac": 0.1},
309
+ "benchmark": {"num_timed_runs": 10, "max_gen_tokens": 80},
310
+ "run_dense_baseline": True,
311
+ "run_multi_seed": False,
312
+ },
313
+ }
314
+
315
+
316
+ def load_preset(name: str) -> ProjectConfig:
317
+ if name not in PRESETS:
318
+ raise ValueError(f"Unknown preset {name!r}, available: {list(PRESETS.keys())}")
319
+ return ProjectConfig._from_dict(PRESETS[name])
320
+
321
+
322
+ # Helpers
323
+ def _asdict_recursive(obj: Any) -> Any:
324
+ if hasattr(obj, "__dataclass_fields__"):
325
+ return {k: _asdict_recursive(v) for k, v in obj.__dict__.items()}
326
+ elif isinstance(obj, (list, tuple)):
327
+ return [_asdict_recursive(v) for v in obj]
328
+ elif isinstance(obj, dict):
329
+ return {k: _asdict_recursive(v) for k, v in obj.items()}
330
+ elif isinstance(obj, torch.device):
331
+ return str(obj)
332
+ return obj
architecture/sparse_moe/datasets.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import sys
3
+ import random
4
+ from typing import Dict, List, Optional, Tuple
5
+
6
+ import torch
7
+ from torch.utils.data import DataLoader, Dataset
8
+
9
+
10
+
11
+ # Workers: 0 on Windows (no fork), 4 on Linux/macOS
12
+ _DATALOADER_WORKERS = 0 if sys.platform == "win32" else 4
13
+
14
+
15
+ # Dataset class
16
+ class DictDataset(Dataset):
17
+
18
+ def __init__(self, input_ids: torch.Tensor, attention_mask: torch.Tensor):
19
+ self.input_ids = input_ids # (N, seq_len)
20
+ self.attention_mask = attention_mask # (N, seq_len)
21
+
22
+ def __len__(self) -> int:
23
+ return self.input_ids.shape[0]
24
+
25
+ def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
26
+ return {
27
+ "input_ids": self.input_ids[idx], # (seq_len,)
28
+ "attention_mask": self.attention_mask[idx], # (seq_len,)
29
+ }
30
+
31
+
32
+ # Tokenisation helpers
33
+ def tokenize_and_batch(texts: List[str], tokenizer, max_len: int, batch_size: int, shuffle: bool = True) -> DataLoader:
34
+ if not texts:
35
+ raise ValueError("tokenize_and_batch requires at least one text sample")
36
+
37
+ enc = tokenizer(
38
+ texts,
39
+ truncation=True,
40
+ padding="max_length",
41
+ max_length=max_len,
42
+ return_tensors="pt",
43
+ )
44
+ dataset = DictDataset(enc["input_ids"], enc["attention_mask"])
45
+ return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle,
46
+ num_workers=_DATALOADER_WORKERS, pin_memory=True)
47
+
48
+
49
+ # Domain text loading
50
+ def load_domain_texts(samples_per_domain: int = 3000, domains: Optional[List[str]] = None) -> Dict[str, List[str]]:
51
+ domains = domains or ["code", "prose", "qa", "math"]
52
+ n = samples_per_domain
53
+ result: Dict[str, List[str]] = {}
54
+
55
+ for domain in domains:
56
+ try:
57
+ if domain == "code":
58
+ result["code"] = _load_code(n)
59
+ elif domain == "prose":
60
+ result["prose"] = _load_prose(n)
61
+ elif domain == "qa":
62
+ result["qa"] = _load_qa(n)
63
+ elif domain == "math":
64
+ result["math"] = _load_math(n)
65
+ else:
66
+ print(f"[WARNING] Unknown domain {domain!r}, skipping")
67
+ continue
68
+ print(f" {domain}: loaded {len(result[domain])} samples")
69
+ except Exception as e:
70
+ print(f"[WARNING] Failed to load {domain}: {e}. Using synthetic fallback.")
71
+ result[domain] = _synthetic_fallback(domain, n)
72
+
73
+ return result
74
+
75
+
76
+ def _load_code(n: int) -> List[str]:
77
+ from datasets import load_dataset
78
+ try:
79
+ ds = load_dataset("code_search_net", "python", split="train")
80
+ texts = [ex["whole_func_string"] for ex in ds if ex.get("whole_func_string") and len(ex["whole_func_string"].strip()) > 50]
81
+ if len(texts) >= n:
82
+ return texts[:n]
83
+ except Exception as e:
84
+ print(f"[WARNING] CodeSearchNet failed: {e}, trying MBPP fallback")
85
+ # Fallback to MBPP (only ~970 samples)
86
+ try:
87
+ ds = load_dataset("mbpp", split="train+test+validation")
88
+ texts = [f"{ex['text']}\n{ex['code']}" for ex in ds if ex.get("code")]
89
+ if 0 < len(texts) < n:
90
+ print(f"[WARNING] Code domain: only {len(texts)} samples available (requested {n}). Duplicating.")
91
+ texts = (texts * (n // len(texts) + 1))[:n]
92
+ return texts[:n]
93
+ except Exception as e:
94
+ print(f"[WARNING] MBPP fallback failed: {e}. Using synthetic fallback.")
95
+ return _synthetic_fallback("code", n)
96
+
97
+
98
+ def _load_prose(n: int) -> List[str]:
99
+ from datasets import load_dataset
100
+ ds = load_dataset("wikitext", "wikitext-2-raw-v1", split="train")
101
+ texts = [t for t in ds["text"] if len(t.strip()) > 50]
102
+ if len(texts) < n:
103
+ import warnings
104
+ warnings.warn(
105
+ f"WikiText-2 has only {len(texts)} usable paragraphs but {n} were "
106
+ f"requested. Duplicating to fill the pool. This may overfit prose.",
107
+ UserWarning, stacklevel=2,
108
+ )
109
+ # Cycle through available texts until we reach n
110
+ texts = (texts * (n // len(texts) + 1))[:n]
111
+ return texts[:n]
112
+
113
+
114
+ def _load_qa(n: int) -> List[str]:
115
+ from datasets import load_dataset
116
+ ds = load_dataset("squad", split="train")
117
+ texts = [
118
+ f"Q: {x['question']}\nA: {x['answers']['text'][0]}"
119
+ for x in ds
120
+ if x.get("answers") and x["answers"].get("text")
121
+ ]
122
+ return texts[:n]
123
+
124
+
125
+ def _load_math(n: int) -> List[str]:
126
+ from datasets import load_dataset
127
+ ds = load_dataset("gsm8k", "main", split="train")
128
+ texts = [f"{x['question']}\n{x['answer']}" for x in ds]
129
+ if len(texts) < n:
130
+ import warnings
131
+ warnings.warn(
132
+ f"GSM8K has only {len(texts)} problems but {n} were requested. "
133
+ f"Duplicating to fill the pool.",
134
+ UserWarning, stacklevel=2,
135
+ )
136
+ texts = (texts * (n // len(texts) + 1))[:n]
137
+ return texts[:n]
138
+
139
+
140
+ def _synthetic_fallback(domain: str, n: int) -> List[str]:
141
+ templates = {
142
+ "code": [
143
+ "def fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)",
144
+ "def factorial(n):\n result = 1\n for i in range(2, n+1):\n result *= i\n return result",
145
+ "class Stack:\n def __init__(self):\n self.items = []\n def push(self, x):\n self.items.append(x)",
146
+ ],
147
+ "prose": [
148
+ "The history of science spans many centuries of intellectual endeavour.",
149
+ "Climate patterns have shifted dramatically over the past century.",
150
+ "Ancient civilisations developed complex mathematical systems.",
151
+ ],
152
+ "qa": [
153
+ "Question: What is machine learning?\nAnswer: Machine learning is a subset of AI.",
154
+ "Question: How does the internet work?\nAnswer: Through a network of interconnected servers.",
155
+ "Question: What causes earthquakes?\nAnswer: Tectonic plate movement.",
156
+ ],
157
+ "math": [
158
+ "Problem: If x + 3 = 7, solve for x.\nSolution: x = 4.",
159
+ "Problem: Calculate 15% of 200.\nSolution: 30.",
160
+ "Problem: Find the area of a rectangle 5×3.\nSolution: 15 square units.",
161
+ ],
162
+ }
163
+ pool = templates.get(domain, templates["prose"])
164
+ return [pool[i % len(pool)] for i in range(n)]
165
+
166
+
167
+ # Train/eval split
168
+ def split_and_build_loaders(domain_texts: Dict[str, List[str]], tokenizer, max_seq_len: int, batch_size: int, eval_frac: float = 0.15, seed: int = 42) -> Tuple[Dict[str, DataLoader], Dict[str, DataLoader]]:
169
+ rng = random.Random(seed)
170
+ train_loaders: Dict[str, DataLoader] = {}
171
+ eval_loaders: Dict[str, DataLoader] = {}
172
+
173
+ for domain, texts in domain_texts.items():
174
+ shuffled = list(texts)
175
+ if not shuffled:
176
+ raise ValueError(f"Domain {domain!r} has no samples to split")
177
+ rng.shuffle(shuffled)
178
+
179
+ if len(shuffled) == 1:
180
+ print("[WARNING] " + str(
181
+ f"Domain {domain!r} has only one sample; duplicating it into train and eval."
182
+ ))
183
+ train_texts = shuffled
184
+ eval_texts = shuffled
185
+ else:
186
+ split_idx = int(len(shuffled) * (1 - eval_frac))
187
+ split_idx = min(max(1, split_idx), len(shuffled) - 1)
188
+ train_texts = shuffled[:split_idx]
189
+ eval_texts = shuffled[split_idx:]
190
+
191
+ train_loaders[domain] = tokenize_and_batch(
192
+ train_texts, tokenizer, max_seq_len, batch_size, shuffle=True
193
+ )
194
+ eval_loaders[domain] = tokenize_and_batch(
195
+ eval_texts, tokenizer, max_seq_len, batch_size, shuffle=False
196
+ )
197
+
198
+ print(
199
+ f" {domain}: {len(train_texts)} train / {len(eval_texts)} eval"
200
+ )
201
+
202
+ return train_loaders, eval_loaders
203
+
204
+
205
+ def build_mixed_loader(domain_loaders: Dict[str, DataLoader], batch_size: int, seed: Optional[int] = None, shuffle: bool = True) -> DataLoader:
206
+ if not domain_loaders:
207
+ raise ValueError("build_mixed_loader requires at least one domain loader")
208
+
209
+ all_ids, all_masks = [], []
210
+ for loader in domain_loaders.values():
211
+ ds = loader.dataset
212
+ all_ids.append(ds.input_ids)
213
+ all_masks.append(ds.attention_mask)
214
+
215
+ combined_ids = torch.cat(all_ids, dim=0) # (N_total, seq_len)
216
+ combined_masks = torch.cat(all_masks, dim=0) # (N_total, seq_len)
217
+
218
+ dataset = DictDataset(combined_ids, combined_masks)
219
+
220
+ generator = torch.Generator()
221
+ if seed is not None:
222
+ generator.manual_seed(seed)
223
+
224
+ return DataLoader(
225
+ dataset, batch_size=batch_size, shuffle=shuffle,
226
+ generator=generator if shuffle else None,
227
+ num_workers=_DATALOADER_WORKERS, pin_memory=True
228
+ )
architecture/sparse_moe/evaluation.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Dict, Optional
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+ from torch.utils.data import DataLoader
9
+
10
+ from .layers import SparseMoELayer
11
+ from .utils import build_labels
12
+
13
+
14
+ def _named_moe_layers(model: nn.Module) -> list[tuple[str, SparseMoELayer]]:
15
+ layers = []
16
+ for idx, module in enumerate(model.modules()):
17
+ if isinstance(module, SparseMoELayer):
18
+ layer_name = getattr(module, "layer_name", f"moe_layer_{idx}")
19
+ layers.append((layer_name, module))
20
+ return layers
21
+
22
+
23
+
24
+ # Core evaluation
25
+ def evaluate_model(model: nn.Module, dataloader: DataLoader, device: Optional[torch.device] = None) -> Dict[str, object]:
26
+ device = device or next(model.parameters()).device
27
+ was_training = model.training
28
+ model.eval()
29
+ moe_layers = _named_moe_layers(model)
30
+
31
+ try:
32
+ total_loss, total_tokens, n_batches = 0.0, 0, 0
33
+ total_correct, total_top5, total_mrr = 0.0, 0.0, 0.0
34
+ entropy_weighted_sum, entropy_weight = 0.0, 0.0
35
+ layer_entropy_sum = {name: 0.0 for name, _ in moe_layers}
36
+ layer_entropy_weight = {name: 0.0 for name, _ in moe_layers}
37
+ layer_weighted_counts = {
38
+ name: torch.zeros(layer.num_experts, dtype=torch.float32)
39
+ for name, layer in moe_layers
40
+ }
41
+ layer_weight_totals = {name: 0.0 for name, _ in moe_layers}
42
+
43
+ for batch in dataloader:
44
+ batch = {k: v.to(device) for k, v in batch.items()}
45
+ labels = build_labels(batch)
46
+ outputs = model(**batch, labels=labels)
47
+
48
+ # Reconstruct sum using same count
49
+ shifted_labels = labels[:, 1:]
50
+ predictable_tokens = int((shifted_labels != -100).sum().item())
51
+
52
+ # Language-modelling loss
53
+ total_loss += outputs.loss.item() * max(predictable_tokens, 1)
54
+ total_tokens += predictable_tokens
55
+ n_batches += 1
56
+
57
+ # Accuracy metrics
58
+ if predictable_tokens > 0 and hasattr(outputs, "logits"):
59
+ logits = outputs.logits # (B, S, V)
60
+ valid_mask = shifted_labels != -100
61
+
62
+ # Top-1 accuracy
63
+ preds = logits[:, :-1].argmax(dim=-1)
64
+ correct = (preds == shifted_labels) & valid_mask
65
+ total_correct += correct.sum().item()
66
+
67
+ # Top-5 accuracy
68
+ top5 = logits[:, :-1].topk(5, dim=-1).indices
69
+ hits = (top5 == shifted_labels.unsqueeze(-1)).any(dim=-1) & valid_mask
70
+ total_top5 += hits.sum().item()
71
+
72
+ # MRR
73
+ valid_logits = logits[:, :-1][valid_mask] # (N_valid, V)
74
+ valid_labels_flat = shifted_labels[valid_mask] # (N_valid,)
75
+ correct_logits = valid_logits.gather(1, valid_labels_flat.unsqueeze(-1)) # (N, 1)
76
+
77
+ n_valid = valid_logits.size(0)
78
+ ranks = torch.zeros(n_valid, device=device, dtype=torch.long)
79
+ chunk_size = 2048
80
+ for i in range(0, n_valid, chunk_size):
81
+ end = i + chunk_size
82
+ ranks[i:end] = (valid_logits[i:end] >= correct_logits[i:end]).sum(dim=-1)
83
+
84
+ total_mrr += (1.0 / ranks.float()).sum().item()
85
+
86
+ # Collect routing stats from MoE layers
87
+ for layer_name, module in moe_layers:
88
+ stats = getattr(module, "_last_stats", None)
89
+ if stats is None:
90
+ continue
91
+
92
+ token_weight = float(stats.get("num_tokens", predictable_tokens))
93
+ entropy_value = float(stats.get("routing_entropy", 0.0))
94
+ layer_entropy_sum[layer_name] += entropy_value * token_weight
95
+ layer_entropy_weight[layer_name] += token_weight
96
+ entropy_weighted_sum += entropy_value * token_weight
97
+ entropy_weight += token_weight
98
+
99
+ fracs = stats.get("expert_fractions", [])
100
+ if len(fracs) != module.num_experts:
101
+ continue
102
+ frac_t = torch.tensor(fracs, dtype=torch.float32)
103
+ assign_weight = float(stats.get("num_assignments", token_weight))
104
+ layer_weighted_counts[layer_name] += frac_t * assign_weight
105
+ layer_weight_totals[layer_name] += assign_weight
106
+
107
+ if n_batches == 0:
108
+ raise ValueError("Evaluation dataloader yielded no batches")
109
+ if total_tokens == 0:
110
+ raise ValueError("Evaluation dataloader contained no predictable tokens")
111
+
112
+ avg_loss = total_loss / max(total_tokens, 1)
113
+ ppl = math.exp(min(avg_loss, 100)) # cap to avoid overflow
114
+
115
+ # Normalise
116
+ utilisation = []
117
+ expert_labels = []
118
+ layer_routing_entropy: Dict[str, float] = {}
119
+ layer_utilisation: Dict[str, list[float]] = {}
120
+ layer_utilisation_std: Dict[str, float] = {}
121
+ util_stds = []
122
+
123
+ for layer_name, module in moe_layers:
124
+ weight_total = layer_weight_totals[layer_name]
125
+ if layer_entropy_weight[layer_name] > 0:
126
+ layer_routing_entropy[layer_name] = (
127
+ layer_entropy_sum[layer_name] / layer_entropy_weight[layer_name]
128
+ )
129
+ else:
130
+ layer_routing_entropy[layer_name] = 0.0
131
+
132
+ if weight_total > 0:
133
+ layer_values = (layer_weighted_counts[layer_name] / weight_total).tolist()
134
+ else:
135
+ layer_values = [0.0] * module.num_experts
136
+ layer_utilisation[layer_name] = layer_values
137
+
138
+ std = torch.tensor(layer_values).std().item() if len(layer_values) > 1 else 0.0
139
+ layer_utilisation_std[layer_name] = std
140
+ util_stds.append(std)
141
+
142
+ for expert_idx, value in enumerate(layer_values):
143
+ expert_labels.append(f"{layer_name}:E{expert_idx}")
144
+ utilisation.append(value)
145
+
146
+ util_std = sum(util_stds) / len(util_stds) if util_stds else 0.0
147
+
148
+ return {
149
+ "perplexity": ppl,
150
+ "avg_loss": avg_loss,
151
+ "bits_per_token": avg_loss / math.log(2), # nats -> bits
152
+ "token_acc": total_correct / max(total_tokens, 1),
153
+ "top5_acc": total_top5 / max(total_tokens, 1),
154
+ "mrr": total_mrr / max(total_tokens, 1),
155
+ "routing_entropy": entropy_weighted_sum / max(entropy_weight, 1.0),
156
+ "layer_routing_entropy": layer_routing_entropy,
157
+ "expert_utilisation": utilisation,
158
+ "expert_labels": expert_labels,
159
+ "layer_expert_utilisation": layer_utilisation,
160
+ "layer_utilisation_std": layer_utilisation_std,
161
+ "utilisation_std": util_std,
162
+ "num_batches": n_batches,
163
+ }
164
+ finally:
165
+ model.train(was_training)
166
+
167
+
168
+ # Per-domain evaluation
169
+ def evaluate_per_domain(model: nn.Module, domain_loaders: Dict[str, DataLoader], device: Optional[torch.device] = None) -> Dict[str, Dict[str, object]]:
170
+ results = {}
171
+ for domain, loader in domain_loaders.items():
172
+ results[domain] = evaluate_model(model, loader, device)
173
+ return results
174
+
175
+
176
+ # Parameter summary
177
+ def parameter_summary(model: nn.Module) -> Dict[str, int]:
178
+ total = sum(p.numel() for p in model.parameters())
179
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
180
+ frozen = total - trainable
181
+
182
+ router_params, expert_params = 0, 0
183
+ for name, p in model.named_parameters():
184
+ if not p.requires_grad:
185
+ continue
186
+ if "routing" in name or ".gate." in name:
187
+ router_params += p.numel()
188
+ else:
189
+ expert_params += p.numel()
190
+
191
+ return {
192
+ "total": total,
193
+ "trainable": trainable,
194
+ "frozen": frozen,
195
+ "router": router_params,
196
+ "experts": expert_params,
197
+ "trainable_pct": 100.0 * trainable / max(total, 1),
198
+ }
199
+
200
+
201
+ # Pretty-print helpers
202
+ def print_domain_report(domain_results: Dict[str, Dict[str, float]]):
203
+ print("\n" + "=" * 62)
204
+ print(" Per-Domain Evaluation")
205
+ print("=" * 62)
206
+ header = f" {'Domain':<15s} {'PPL':>8s} {'Loss':>8s} {'Top-1 Acc':>10s} {'Top-5 Acc':>10s}"
207
+ print(header)
208
+ print(" " + "-" * (len(header) - 2))
209
+ for domain, m in domain_results.items():
210
+ top1 = f"{m.get('token_acc', 0.0) * 100:.1f}%"
211
+ top5 = f"{m.get('top5_acc', 0.0) * 100:.1f}%"
212
+ print(f" {domain:<15s} {m['perplexity']:>8.2f} {m['avg_loss']:>8.4f} "
213
+ f"{top1:>10s} {top5:>10s}")
214
+ print("=" * 62 + "\n")
215
+
216
+
217
+ def print_report(metrics: Dict[str, float], params: Dict[str, int]):
218
+ print("\n" + "=" * 55)
219
+ print(" Sparse MoE — Evaluation Report")
220
+ print("=" * 55)
221
+
222
+ print(f"\n Perplexity : {metrics['perplexity']:.2f}")
223
+ print(f" Avg loss : {metrics['avg_loss']:.4f}")
224
+ print(f" Routing entropy : {metrics['routing_entropy']:.4f}")
225
+ print(f" Utilisation std : {metrics['utilisation_std']:.4f}")
226
+
227
+ util = metrics.get("expert_utilisation", [])
228
+ labels = metrics.get("expert_labels", [])
229
+ if util and labels:
230
+ ranked = sorted(zip(labels, util), key=lambda item: item[1], reverse=True)
231
+ print(f" Top layer-experts :")
232
+ for label, value in ranked[: min(10, len(ranked))]:
233
+ print(f" {label:<28s} {value:.3f}")
234
+
235
+ print(f"\n Parameters:")
236
+ print(f" Total : {params['total']:>12,}")
237
+ print(f" Trainable : {params['trainable']:>12,} ({params['trainable_pct']:.2f}%)")
238
+ print(f" Router : {params['router']:>12,}")
239
+ print(f" Experts : {params['experts']:>12,}")
240
+ print(f" Frozen : {params['frozen']:>12,}")
241
+ print("=" * 55 + "\n")
architecture/sparse_moe/experts.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Union
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.nn.functional as F
9
+
10
+ from .config import ExpertConfig
11
+
12
+
13
+
14
+ # LoRA Adapter Expert
15
+ class LoRAAdapter(nn.Module):
16
+
17
+ def __init__(self, d_model: int, config: ExpertConfig):
18
+ super().__init__()
19
+ r = config.lora_rank
20
+ self.scaling = config.lora_alpha / r
21
+
22
+ # A: down-projection (d_model → rank)
23
+ # B: up-projection (rank → d_model)
24
+ self.lora_A = nn.Parameter(torch.empty(r, d_model))
25
+ self.lora_B = nn.Parameter(torch.empty(d_model, r))
26
+ self.dropout = nn.Dropout(config.lora_dropout)
27
+
28
+ nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
29
+ nn.init.normal_(self.lora_B, std=0.01)
30
+
31
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
32
+ out = F.linear(self.dropout(x), self.lora_A)
33
+ out = F.linear(out, self.lora_B)
34
+ return out * self.scaling
35
+
36
+ def extra_repr(self) -> str:
37
+ r = self.lora_A.shape[0]
38
+ d = self.lora_A.shape[1]
39
+ return f"d_model={d}, rank={r}, scaling={self.scaling:.2f}"
40
+
41
+
42
+ # Batched LoRA Experts
43
+ class BatchedLoRAExperts(nn.Module):
44
+ def __init__(self, d_model: int, config: ExpertConfig, num_experts: int):
45
+ super().__init__()
46
+ self.num_experts = num_experts
47
+ r = config.lora_rank
48
+ self.scaling = config.lora_alpha / r
49
+
50
+ # A: (num_experts, r, d_model)
51
+ self.lora_A = nn.Parameter(torch.empty(num_experts, r, d_model))
52
+ # B: (num_experts, d_model, r)
53
+ self.lora_B = nn.Parameter(torch.empty(num_experts, d_model, r))
54
+ self.dropout = nn.Dropout(config.lora_dropout)
55
+
56
+ for i in range(num_experts):
57
+ nn.init.kaiming_uniform_(self.lora_A[i], a=math.sqrt(5))
58
+ nn.init.normal_(self.lora_B[i], std=0.01)
59
+
60
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
61
+ x = self.dropout(x)
62
+ # (E, C, D) @ (E, D, r) -> (E, C, r)
63
+ out = torch.bmm(x, self.lora_A.transpose(1, 2))
64
+
65
+ # (E, C, r) @ (E, r, d) -> (E, C, D)
66
+ out = torch.bmm(out, self.lora_B.transpose(1, 2))
67
+
68
+ return out * self.scaling
69
+
70
+ def extra_repr(self) -> str:
71
+ r = self.lora_A.shape[1]
72
+ d = self.lora_A.shape[2]
73
+ return f"d_model={d}, rank={r}, num_experts={self.num_experts}, scaling={self.scaling:.2f}"
74
+
75
+
76
+ # TinyExpert MLP
77
+ _ACTIVATIONS = {"gelu": nn.GELU, "silu": nn.SiLU, "relu": nn.ReLU}
78
+
79
+
80
+ class TinyExpert(nn.Module):
81
+
82
+ def __init__(self, d_model: int, config: ExpertConfig):
83
+ super().__init__()
84
+ intermediate = config.tiny_intermediate or d_model * 4
85
+
86
+ self.fc1 = nn.Linear(d_model, intermediate, bias=False)
87
+ self.fc2 = nn.Linear(intermediate, d_model, bias=False)
88
+ self.dropout = nn.Dropout(config.tiny_dropout)
89
+
90
+ act_cls = _ACTIVATIONS.get(config.tiny_activation, nn.SiLU)
91
+ self.activation = act_cls()
92
+
93
+ self._init_weights()
94
+
95
+ def _init_weights(self):
96
+ for linear in (self.fc1, self.fc2):
97
+ nn.init.kaiming_uniform_(linear.weight, a=math.sqrt(5))
98
+
99
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
100
+ h = self.activation(self.fc1(x))
101
+ h = self.dropout(h)
102
+ return self.fc2(h)
103
+
104
+ def extra_repr(self) -> str:
105
+ return (f"d_model={self.fc1.in_features}, "
106
+ f"intermediate={self.fc1.out_features}")
107
+
108
+
109
+ # Factory
110
+ def create_experts(d_model: int, config: ExpertConfig, num_experts: int) -> Union[BatchedLoRAExperts, nn.ModuleList]:
111
+ if config.expert_type == "lora":
112
+ return BatchedLoRAExperts(d_model, config, num_experts)
113
+ elif config.expert_type == "tiny":
114
+ experts = [TinyExpert(d_model, config) for _ in range(num_experts)]
115
+ else:
116
+ raise ValueError(f"Unknown expert_type: {config.expert_type!r}")
117
+
118
+ return nn.ModuleList(experts)
architecture/sparse_moe/injection.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from functools import wraps
5
+ from typing import Set
6
+
7
+ import torch.nn as nn
8
+
9
+ from .config import ExpertConfig, RouterConfig
10
+ from .layers import SparseMoELayer
11
+
12
+ # Maps model_type → set of FFN class names to replace
13
+ _FFN_CLASS_NAMES: dict[str, Set[str]] = {
14
+ "gpt2": {"GPT2MLP"},
15
+ "llama": {"LlamaMLP"},
16
+ "mistral": {"MistralMLP"},
17
+ "phi": {"PhiMLP", "Phi3MLP", "PhiMoEMLP"},
18
+ "phi3": {"PhiMLP", "Phi3MLP"},
19
+ "qwen2": {"Qwen2MLP"},
20
+ "gemma": {"GemmaMLP"},
21
+ "gemma2": {"GemmaMLP"},
22
+ }
23
+
24
+
25
+ def _is_ffn(module: nn.Module, model_type: str) -> bool:
26
+ class_name = type(module).__name__
27
+
28
+ # Known architectures
29
+ if model_type in _FFN_CLASS_NAMES:
30
+ return class_name in _FFN_CLASS_NAMES[model_type]
31
+
32
+ # Heuristic fallback for unknown architectures
33
+ heuristic_names = {"MLP", "FFN", "FeedForward", "MoEMLP"}
34
+ is_match = any(h in class_name for h in heuristic_names)
35
+
36
+ if is_match:
37
+ print("[WARNING] " +
38
+ f"Unknown model_type={model_type!r}: using heuristic to detect FFN "
39
+ f"(matched class {class_name!r}). Verify this is correct."
40
+ )
41
+
42
+ return is_match
43
+
44
+
45
+ def _set_module(root: nn.Module, dotted_name: str, replacement: nn.Module):
46
+ parts = dotted_name.split(".")
47
+ parent = root
48
+ for part in parts[:-1]:
49
+ parent = getattr(parent, part)
50
+ setattr(parent, parts[-1], replacement)
51
+
52
+
53
+ # Main injection function
54
+ def inject_moe_layers(model: nn.Module, expert_config: ExpertConfig, router_config: RouterConfig, capacity_factor: float = 1.25) -> nn.Module:
55
+ model_type = getattr(model.config, "model_type", "qwen2")
56
+ d_model = model.config.hidden_size
57
+ print(
58
+ f"Injecting MoE layers: model_type={model_type!r}, d_model={d_model}, "
59
+ f"experts={router_config.num_experts}, top_k={router_config.top_k}, "
60
+ f"type={expert_config.expert_type}"
61
+ )
62
+
63
+ for param in model.parameters():
64
+ param.requires_grad = False
65
+
66
+ replacements = []
67
+ for name, module in model.named_modules():
68
+ if _is_ffn(module, model_type):
69
+ replacements.append((name, module))
70
+
71
+ if not replacements:
72
+ raise ValueError(
73
+ f"No FFN layers found for model_type={model_type!r}. "
74
+ f"The model was not modified. Check architecture support before training."
75
+ )
76
+
77
+ for name, original_ffn in replacements:
78
+ base_ffn = original_ffn if expert_config.expert_type == "lora" else None
79
+
80
+ try:
81
+ layer_device = next(original_ffn.parameters()).device
82
+ layer_dtype = next(original_ffn.parameters()).dtype
83
+ except StopIteration:
84
+ layer_device = next(model.parameters()).device
85
+ layer_dtype = next(model.parameters()).dtype
86
+
87
+ moe_layer = SparseMoELayer(
88
+ d_model=d_model,
89
+ expert_config=expert_config,
90
+ router_config=router_config,
91
+ base_ffn=base_ffn,
92
+ capacity_factor=capacity_factor,
93
+ ).to(device=layer_device, dtype=layer_dtype)
94
+ moe_layer.layer_name = name
95
+ _set_module(model, name, moe_layer)
96
+
97
+ _install_attention_mask_context(model)
98
+
99
+ return model
100
+
101
+
102
+ def _install_attention_mask_context(model: nn.Module):
103
+ moe_layers = [m for m in model.modules() if isinstance(m, SparseMoELayer)]
104
+ model._moe_layers = moe_layers
105
+
106
+ if getattr(model, "_moe_attention_context_installed", False):
107
+ return
108
+
109
+ original_forward = model.forward
110
+ forward_signature = inspect.signature(original_forward)
111
+
112
+ @wraps(original_forward)
113
+ def wrapped_forward(*args, **kwargs):
114
+ attention_mask = kwargs.get("attention_mask")
115
+ if attention_mask is None:
116
+ try:
117
+ bound = forward_signature.bind_partial(*args, **kwargs)
118
+ attention_mask = bound.arguments.get("attention_mask")
119
+ except TypeError as e:
120
+ print(f"[DEBUG] Could not bind arguments to trace attention_mask: {e}")
121
+ attention_mask = None
122
+
123
+ current_layers = getattr(model, "_moe_layers", [])
124
+ for layer in current_layers:
125
+ layer._current_attention_mask = attention_mask
126
+ return original_forward(*args, **kwargs)
127
+
128
+ model.forward = wrapped_forward
129
+ model._moe_attention_context_installed = True
architecture/sparse_moe/layers.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Optional
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+
9
+ from .config import ExpertConfig, RouterConfig
10
+ from .routing import LinearRouter, RoutingOutput
11
+ from .experts import BatchedLoRAExperts, create_experts
12
+
13
+
14
+ # Sparse MoE Layer
15
+ class SparseMoELayer(nn.Module):
16
+
17
+ def __init__(
18
+ self,
19
+ d_model: int,
20
+ expert_config: ExpertConfig,
21
+ router_config: RouterConfig,
22
+ base_ffn: Optional[nn.Module] = None,
23
+ capacity_factor: float = 1.25,
24
+ ):
25
+ super().__init__()
26
+ self.d_model = d_model
27
+ self.num_experts = router_config.num_experts
28
+ self.top_k = router_config.top_k
29
+ self.expert_type = expert_config.expert_type
30
+ self.capacity_factor = capacity_factor
31
+ self.layer_name = "unassigned"
32
+
33
+ # Router
34
+ self.routing = LinearRouter(d_model, router_config)
35
+
36
+ # Experts
37
+ self.experts = create_experts(d_model, expert_config, router_config.num_experts)
38
+
39
+ # Base FFN (frozen, used only in LoRA mode)
40
+ self.base_ffn: Optional[nn.Module] = None
41
+ if expert_config.expert_type == "lora":
42
+ if base_ffn is None:
43
+ raise ValueError("base_ffn is required for LoRA mode")
44
+ self.base_ffn = base_ffn
45
+ # Freeze the base FFN
46
+ for p in self.base_ffn.parameters():
47
+ p.requires_grad = False
48
+
49
+ # Cache for trainer/evaluator to read
50
+ self._last_aux_loss: Optional[torch.Tensor] = None
51
+ self._last_stats: Optional[dict] = None
52
+ self._current_attention_mask: Optional[torch.Tensor] = None
53
+
54
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
55
+ B, S, D = x.shape
56
+
57
+ # ── Base FFN (LoRA mode) ──
58
+ if self.base_ffn is not None:
59
+ with torch.no_grad():
60
+ base_out = self.base_ffn(x)
61
+ else:
62
+ base_out = torch.zeros_like(x) # (B, S, D)
63
+
64
+ attention_mask = self._resolve_attention_mask(B, S, x.device)
65
+
66
+ # ── Routing ──
67
+ routing = self.routing(x, attention_mask=attention_mask)
68
+
69
+ # ── Sparse expert dispatch ──
70
+ expert_out = self._sparse_forward(x, routing, B, S, D, attention_mask)
71
+
72
+ # ── Combine ──
73
+ output = base_out + expert_out.to(base_out.dtype)
74
+
75
+ # Cache for downstream
76
+ self._last_aux_loss = routing.aux_loss
77
+ self._last_stats = routing.stats
78
+
79
+ return output
80
+
81
+ def _resolve_attention_mask(self, batch_size: int, seq_len: int, device: torch.device) -> Optional[torch.Tensor]:
82
+ attention_mask = self._current_attention_mask
83
+ if attention_mask is None:
84
+ return None
85
+
86
+ if attention_mask.dim() > 2:
87
+ if attention_mask.dim() == 4:
88
+ # Handle 4D causal masks e.g. [B, 1, S, S] → take last row
89
+ attention_mask = attention_mask[:, 0, -1, :]
90
+ elif attention_mask.dim() == 3:
91
+ attention_mask = attention_mask[:, -1, :]
92
+ else:
93
+ attention_mask = attention_mask.reshape(batch_size, -1)
94
+ if attention_mask.shape[1] != seq_len:
95
+ if attention_mask.shape[1] < seq_len:
96
+ raise ValueError(
97
+ f"attention_mask length {attention_mask.shape[1]} does not match "
98
+ f"hidden state length {seq_len} for {self.layer_name}"
99
+ )
100
+ attention_mask = attention_mask[:, -seq_len:]
101
+ return attention_mask.to(device=device, dtype=torch.bool)
102
+
103
+ def _sparse_forward(
104
+ self,
105
+ x: torch.Tensor,
106
+ routing: RoutingOutput,
107
+ B: int, S: int, D: int,
108
+ attention_mask: Optional[torch.Tensor] = None,
109
+ ) -> torch.Tensor:
110
+ if isinstance(self.experts, BatchedLoRAExperts):
111
+ return self._batched_sparse_forward(x, routing, B, S, D, attention_mask)
112
+ return self._loop_sparse_forward(x, routing, B, S, D, attention_mask)
113
+
114
+ def _loop_sparse_forward(self, x: torch.Tensor, routing: RoutingOutput, B: int, S: int, D: int, attention_mask: Optional[torch.Tensor] = None) -> torch.Tensor:
115
+ flat_x = x.reshape(B * S, D)
116
+ combined = torch.zeros_like(flat_x)
117
+ flat_valid = self._flat_valid_mask(attention_mask, B, S, flat_x.device)
118
+
119
+ # Explicit graph break to cleanly branch out if zero tokens
120
+ num_valid_int = int(flat_valid.sum().item())
121
+ if num_valid_int == 0:
122
+ return combined.reshape(B, S, D)
123
+
124
+ flat_indices = routing.top_k_indices.reshape(B * S, self.top_k)
125
+ flat_weights = routing.top_k_weights.reshape(B * S, self.top_k)
126
+
127
+ # Capacity: max tokens any single expert may handle
128
+ expected = math.ceil(num_valid_int * self.top_k / self.num_experts)
129
+ capacity = max(1, int(self.capacity_factor * expected))
130
+
131
+ # ── Per-expert dispatch ──
132
+ for e_idx, expert in enumerate(self.experts):
133
+ mask = (flat_indices == e_idx) & flat_valid.unsqueeze(-1)
134
+ token_positions, k_slots = torch.where(mask)
135
+
136
+ if token_positions.numel() == 0:
137
+ continue
138
+
139
+ if token_positions.numel() > capacity:
140
+ weights_for_sort = flat_weights[token_positions, k_slots]
141
+ keep = torch.topk(weights_for_sort, k=capacity, largest=True, sorted=False).indices
142
+ token_positions = token_positions[keep]
143
+ k_slots = k_slots[keep]
144
+
145
+ # Gather tokens for this expert
146
+ expert_input = flat_x[token_positions] # (num_selected, D)
147
+
148
+ # Run expert
149
+ expert_output = expert(expert_input) # (num_selected, D)
150
+
151
+ weights = flat_weights[token_positions, k_slots]
152
+ weighted_output = expert_output * weights.unsqueeze(-1)
153
+
154
+ # Coalesce duplicate token indices first so the final scatter uses
155
+ # unique destinations only. This avoids CUDA backward bugs around
156
+ # index_add_ with repeated indices on top-k routed tokens.
157
+ unique_pos, reduced_output = self._coalesce_duplicate_dispatch(
158
+ token_positions,
159
+ weighted_output,
160
+ combined.dtype,
161
+ )
162
+ combined.index_add_(0, unique_pos, reduced_output)
163
+
164
+ return combined.reshape(B, S, D) # (B, S, D)
165
+
166
+ def _batched_sparse_forward(self, x: torch.Tensor, routing: RoutingOutput, B: int, S: int, D: int, attention_mask: Optional[torch.Tensor] = None) -> torch.Tensor:
167
+ if not getattr(torch.compiler, "is_compiling", lambda: False)():
168
+ return self._forward_dynamic(x, routing, B, S, D, attention_mask)
169
+ return self._forward_padded_loop(x, routing, B, S, D, attention_mask)
170
+
171
+ def _forward_dynamic(
172
+ self, x: torch.Tensor, routing: RoutingOutput, B: int, S: int, D: int,
173
+ attention_mask: Optional[torch.Tensor]
174
+ ) -> torch.Tensor:
175
+ N = B * S
176
+ flat_x = x.reshape(N, D)
177
+ combined = torch.zeros_like(flat_x)
178
+ flat_valid = self._flat_valid_mask(attention_mask, B, S, flat_x.device)
179
+
180
+ num_valid_int = int(flat_valid.sum().item())
181
+ if num_valid_int == 0:
182
+ return combined.reshape(B, S, D)
183
+
184
+ flat_indices = routing.top_k_indices.reshape(N, self.top_k)
185
+ flat_weights = routing.top_k_weights.reshape(N, self.top_k)
186
+
187
+ expected = math.ceil(num_valid_int * self.top_k / self.num_experts)
188
+ capacity = max(1, int(self.capacity_factor * expected))
189
+ E, C = self.num_experts, capacity
190
+
191
+ one_hot = torch.zeros(N * self.top_k, E, dtype=torch.long, device=flat_x.device)
192
+ one_hot.scatter_(1, flat_indices.reshape(-1, 1), 1)
193
+
194
+ cum_count = one_hot.cumsum(0)
195
+ slot = ((cum_count - 1) * one_hot).sum(-1)
196
+
197
+ valid_flat = flat_valid.unsqueeze(-1).expand(N, self.top_k).reshape(-1)
198
+ capacity_mask = valid_flat & (slot < C)
199
+
200
+ active = capacity_mask.nonzero(as_tuple=False).squeeze(-1)
201
+ if active.numel() == 0:
202
+ return combined.reshape(B, S, D)
203
+
204
+ active_token_idx = (active // self.top_k)
205
+ active_expert_idx = flat_indices.reshape(-1)[active]
206
+ active_slot_idx = slot[active]
207
+ active_weights = flat_weights.reshape(-1)[active]
208
+
209
+ buf_idx = active_expert_idx * C + active_slot_idx
210
+
211
+ batched_input = flat_x.new_zeros(E * C, D)
212
+ batched_input.index_copy_(0, buf_idx, flat_x[active_token_idx])
213
+ batched_input = batched_input.reshape(E, C, D)
214
+
215
+ expert_out = self.experts(batched_input)
216
+ expert_out_flat = expert_out.reshape(E * C, D)
217
+
218
+ weighted_out = expert_out_flat[buf_idx] * active_weights.unsqueeze(-1)
219
+
220
+ unique_pos, reduced_out = self._coalesce_duplicate_dispatch(
221
+ active_token_idx, weighted_out, combined.dtype,
222
+ )
223
+ combined.index_add_(0, unique_pos, reduced_out)
224
+
225
+ return combined.reshape(B, S, D)
226
+
227
+ def _forward_padded_loop(
228
+ self, x: torch.Tensor, routing: RoutingOutput, B: int, S: int, D: int,
229
+ attention_mask: Optional[torch.Tensor]
230
+ ) -> torch.Tensor:
231
+ flat_x = x.reshape(B * S, D)
232
+ combined = torch.zeros_like(flat_x)
233
+ flat_valid = self._flat_valid_mask(attention_mask, B, S, flat_x.device)
234
+
235
+ num_valid_int = int(flat_valid.sum().item())
236
+ if num_valid_int == 0:
237
+ return combined.reshape(B, S, D)
238
+
239
+ flat_indices = routing.top_k_indices.reshape(B * S, self.top_k)
240
+ flat_weights = routing.top_k_weights.reshape(B * S, self.top_k)
241
+
242
+ expected = math.ceil(num_valid_int * self.top_k / self.num_experts)
243
+ capacity = max(1, int(self.capacity_factor * expected))
244
+
245
+ inputs, positions, weights, valids = [], [], [], []
246
+
247
+ for e_idx in range(self.num_experts):
248
+ mask = (flat_indices == e_idx) & flat_valid.unsqueeze(-1)
249
+ token_idx, k_slots = torch.where(mask)
250
+
251
+ if token_idx.numel() > capacity:
252
+ w_sort = flat_weights[token_idx, k_slots]
253
+ keep = torch.topk(w_sort, k=capacity, largest=True, sorted=False).indices
254
+ token_idx = token_idx[keep]
255
+ k_slots = k_slots[keep]
256
+
257
+ n_tokens = token_idx.numel()
258
+
259
+ pad_inputs = torch.zeros(capacity, D, dtype=flat_x.dtype, device=flat_x.device)
260
+ pad_pos = torch.zeros(capacity, dtype=torch.long, device=flat_x.device)
261
+ pad_weights = torch.zeros(capacity, dtype=flat_weights.dtype, device=flat_x.device)
262
+ pad_valid = torch.zeros(capacity, dtype=torch.bool, device=flat_x.device)
263
+
264
+ if n_tokens > 0:
265
+ pad_inputs[:n_tokens] = flat_x[token_idx]
266
+ pad_pos[:n_tokens] = token_idx
267
+ pad_weights[:n_tokens] = flat_weights[token_idx, k_slots]
268
+ pad_valid[:n_tokens] = True
269
+
270
+ inputs.append(pad_inputs)
271
+ positions.append(pad_pos)
272
+ weights.append(pad_weights)
273
+ valids.append(pad_valid)
274
+
275
+ batched_input = torch.stack(inputs) # (E, C, D)
276
+ batched_pos = torch.stack(positions).reshape(-1) # (E * C,)
277
+ batched_weights = torch.stack(weights).reshape(-1, 1) # (E * C, 1)
278
+ batched_valid = torch.stack(valids).reshape(-1) # (E * C,)
279
+
280
+ expert_out = self.experts(batched_input) # (E, C, D)
281
+ expert_out = expert_out.reshape(-1, D) # (E * C, D)
282
+
283
+ weighted_out = expert_out * batched_weights # (E * C, D)
284
+
285
+ if batched_valid.any():
286
+ unique_pos, reduced_output = self._coalesce_duplicate_dispatch(
287
+ batched_pos[batched_valid],
288
+ weighted_out[batched_valid],
289
+ combined.dtype,
290
+ )
291
+ combined.index_add_(0, unique_pos, reduced_output)
292
+
293
+ return combined.reshape(B, S, D)
294
+
295
+ def _coalesce_duplicate_dispatch(self, token_positions: torch.Tensor, weighted_output: torch.Tensor, out_dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor]:
296
+ if token_positions.numel() == 0:
297
+ empty = weighted_output.new_zeros((0, weighted_output.shape[-1]), dtype=out_dtype)
298
+ return token_positions, empty
299
+
300
+ weighted_output = weighted_output.to(out_dtype).contiguous()
301
+ if token_positions.numel() == 1:
302
+ return token_positions, weighted_output
303
+
304
+ order = torch.argsort(token_positions)
305
+ sorted_pos = token_positions[order]
306
+ sorted_out = weighted_output[order]
307
+
308
+ is_new = torch.ones_like(sorted_pos, dtype=torch.bool)
309
+ is_new[1:] = sorted_pos[1:] != sorted_pos[:-1]
310
+ start_idx = torch.nonzero(is_new, as_tuple=False).flatten()
311
+ end_idx = torch.empty_like(start_idx)
312
+ if start_idx.numel() > 1:
313
+ end_idx[:-1] = start_idx[1:] - 1
314
+ end_idx[-1] = sorted_pos.numel() - 1
315
+
316
+ cumsum = sorted_out.cumsum(dim=0)
317
+ reduced = cumsum[end_idx].clone()
318
+ if start_idx.numel() > 1:
319
+ reduced[1:] -= cumsum[start_idx[1:] - 1]
320
+
321
+ return sorted_pos[start_idx], reduced.to(out_dtype).contiguous()
322
+
323
+ def _flat_valid_mask(
324
+ self,
325
+ attention_mask: Optional[torch.Tensor],
326
+ batch_size: int,
327
+ seq_len: int,
328
+ device: torch.device,
329
+ ) -> torch.Tensor:
330
+ """Flatten a token-validity mask, defaulting to all tokens valid."""
331
+ if attention_mask is None:
332
+ return torch.ones(batch_size * seq_len, dtype=torch.bool, device=device)
333
+ return attention_mask.reshape(batch_size * seq_len).to(device=device, dtype=torch.bool)
334
+
335
+ def extra_repr(self) -> str:
336
+ mode = "lora+residual" if self.base_ffn is not None else "tiny+replace"
337
+ return (f"d_model={self.d_model}, experts={self.num_experts}, "
338
+ f"top_k={self.top_k}, mode={mode}, capacity={self.capacity_factor}")
architecture/sparse_moe/prompts.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DOMAIN_PROMPTS = {
2
+ "code": [
3
+ "def quicksort(arr):\n if len(arr) <= 1:\n return arr",
4
+ "class BinarySearchTree:\n def __init__(self):\n self.root = None",
5
+ "import torch\nimport torch.nn as nn\n\nclass TransformerBlock(nn.Module):",
6
+ "def solve_n_queens(n):\n \"\"\"Return all distinct solutions to the n-queens puzzle.\"\"\"",
7
+ "async def fetch_url(url):\n import aiohttp\n async with aiohttp.ClientSession() as session:",
8
+ "def fibonacci_memo(n, memo={}):\n \"\"\"Compute Fibonacci with memoization.\"\"\"",
9
+ "def deep_copy_tree(node):\n \"\"\"Create a deep copy of a binary tree.\"\"\"",
10
+ "def detect_cycle_in_linked_list(head):\n \"\"\"Floyd's cycle-finding algorithm.\"\"\"",
11
+ "def parse_log_file(filename):\n \"\"\"Efficiently parse a large log file with regex.\"\"\"",
12
+ "def rotate_matrix_90_degrees(matrix):\n \"\"\"Rotate an n x n matrix in-place.\"\"\"",
13
+ "def is_palindrome_string(s):\n \"\"\"Check if string is a palindrome ignoring case/spacing.\"\"\"",
14
+ "def compute_levenshtein_distance(s1, s2):\n \"\"\"Compute edit distance between two strings.\"\"\"",
15
+ ],
16
+ "reasoning": [
17
+ "If Mary is twice as old as John was when Mary was... wait. Assume Mary is 40. Then John is",
18
+ "The murderer must be either the butler or the gardener. Since the gardener was seen in town,",
19
+ "If all Bloops are Razzies and all Razzies are Lurgs, then it must be true that",
20
+ "A man has to get a fox, a chicken, and a sack of corn across a river in a small boat.",
21
+ "Complete the sequence and explain the logic: 2, 6, 12, 20, 30, ...",
22
+ "If you have two buckets, one that holds 3 liters and one that holds 5 liters, how do you get exactly 4 liters?",
23
+ "Three gods A, B, and C are called True, False, and Random. True always speaks truly...",
24
+ "A grandfather, two fathers, and two sons went to the movie theater together and bought one ticket each.",
25
+ "What has keys but no locks, space but no room, and allows you to enter but never leave?",
26
+ "Which is heavier: a ton of bricks or a ton of feathers? Explain your reasoning.",
27
+ "I speak without a mouth and hear without ears. I have no body, but I come alive with wind. What am I?",
28
+ "If a plane crashes on the border between the United States and Canada, where do they bury the survivors?",
29
+ ],
30
+ "prose": [
31
+ "The history of artificial intelligence began in the mid-20th century when",
32
+ "Deep beneath the ocean's surface, where sunlight cannot reach,",
33
+ "The neon lights of Neo-Tokyo flickered against the damp pavement as",
34
+ "The time traveler stepped out of the capsule and inhaled the pungent air of 1666 London.",
35
+ "To explain quantum entanglement, imagine two magical dice that always show the same number",
36
+ "Describe the sensation of waking up on a planet with two suns and purple grass.",
37
+ "Write a letter from a pioneer in the year 1850 describing their journey across the American West.",
38
+ "A dialogue between a cynical detective and a naive robot about the nature of justice.",
39
+ "Describe the architecture of a city built entirely inside a giant, hollowed-out tree.",
40
+ "Tell a short fairy tale about a weaver who could spin moonlight into cloth.",
41
+ "Explain the concept of 'Ikigai' and how it relates to modern work-life balance.",
42
+ "Describe the smells and sounds of a bustling Spice Market in ancient Marrakesh.",
43
+ ],
44
+ "qa": [
45
+ "Question: What is the difference between supervised and unsupervised learning?\nAnswer:",
46
+ "Question: How do transformers achieve parallelism compared to RNNs?\nAnswer:",
47
+ "Question: What is the main objective of a Generative Adversarial Network (GAN)?\nAnswer:",
48
+ "Question: Explain the vanishing gradient problem and how Residual Connections help.\nAnswer:",
49
+ "Question: What are the primary benefits of using BFloat16 over Float16 for LLM training?\nAnswer:",
50
+ "Question: Explain the difference between Model Parallelism and Data Parallelism in deep learning.\nAnswer:",
51
+ "Question: What is 'Temperature' in the context of LLM sampling and how does it effect output diversity?\nAnswer:",
52
+ "Question: What is the 'Attention Bottleneck' in large scale transformers?\nAnswer:",
53
+ "Question: Define 'Zero-Shot Learning' and provide an example of its application.\nAnswer:",
54
+ "Question: What are the trade-offs between dense models and Mixture-of-Experts (MoE) models?\nAnswer:",
55
+ "Question: Explain the concept of 'Reward Modeling' in RLHF.\nAnswer:",
56
+ "Question: What is the difference between a Softmax and a Gumbel-Softmax?\nAnswer:",
57
+ ],
58
+ "math": [
59
+ "Problem: Prove that the sum of the first n natural numbers equals n(n+1)/2.\nSolution:",
60
+ "Problem: Find the derivative of f(x) = x³ · sin(x).\nSolution:",
61
+ "Problem: Solve for x in the quadratic equation 2x² - 5x + 3 = 0.\nSolution:",
62
+ "Problem: Calculate the volume of a sphere with a radius of 4 units.\nSolution:",
63
+ "Problem: If a train travels 450 km in 5 hours, what is its average speed in km/h?\nSolution:",
64
+ "Problem: Find the integral of f(x) = e^(2x) from 0 to 1.\nSolution:",
65
+ "Problem: In a triangle with sides measuring 7, 24, and 25, identify if it is a right triangle.\nSolution:",
66
+ "Problem: Calculate the probability of flipping exactly 3 heads in 5 coin tosses.\nSolution:",
67
+ "Problem: Solve for x: log2(x + 3) = 4.\nSolution:",
68
+ "Problem: A rectangular garden has a perimeter of 100 meters and its length is twice its width. Find dimensions.\nSolution:",
69
+ "Problem: Calculate the dot product of vectors A = [2, 5, -1] and B = [3, -2, 4].\nSolution:",
70
+ "Problem: Find the local maximum of the function f(x) = -x² + 6x - 5.\nSolution:",
71
+ ],
72
+ }
architecture/sparse_moe/reporting.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+ import numpy as np
9
+ import torch
10
+
11
+ from .visualization import (
12
+ HAS_MATPLOTLIB,
13
+ DomainScorePlot,
14
+ TrainingConvergencePlot,
15
+ ExpertRoutingHeatmap,
16
+ )
17
+
18
+ from .config import ProjectConfig
19
+ from .evaluation import evaluate_per_domain
20
+ from .datasets import build_mixed_loader
21
+ from .injection import inject_moe_layers
22
+ from .trainer import MoETrainer
23
+ from .utils import set_seed, to_serializable, parse_seed_list
24
+
25
+ # Before / after comparison
26
+ def build_before_after_rows(before: Dict[str, Dict], after: Dict[str, Dict], dense: Optional[Dict[str, Dict]] = None) -> List[Dict]:
27
+ rows = []
28
+ for domain in before:
29
+ before_ent = before[domain]["routing_entropy"]
30
+ after_ent = after[domain]["routing_entropy"]
31
+ row = {
32
+ "domain": domain,
33
+ "ppl_before": before[domain]["perplexity"],
34
+ "ppl_after": after[domain]["perplexity"],
35
+ "ppl_change": after[domain]["perplexity"] - before[domain]["perplexity"],
36
+ "entropy_before": before_ent if before_ent > 0 else None,
37
+ "entropy_after": after_ent,
38
+ }
39
+ if dense and domain in dense:
40
+ row["ppl_dense"] = dense[domain]["perplexity"]
41
+ row["moe_vs_dense"] = after[domain]["perplexity"] - dense[domain]["perplexity"]
42
+ rows.append(row)
43
+ return rows
44
+
45
+
46
+ def print_before_after_report(rows: List[Dict], include_dense: bool):
47
+ print("\n" + "=" * 75)
48
+ print(" Pretrained vs Trained — Per-Domain Perplexity")
49
+ print("=" * 75)
50
+
51
+ if include_dense:
52
+ header = (f" {'Domain':<10s} {'Before':>8s} {'After':>8s} {'Δ':>8s} "
53
+ f"{'Dense':>8s} {'MoE−Dense':>10s}")
54
+ else:
55
+ header = f" {'Domain':<10s} {'Before':>8s} {'After':>8s} {'Δ':>8s}"
56
+ print(header)
57
+ print(" " + "-" * (len(header) - 2))
58
+
59
+ for r in rows:
60
+ delta_sym = "↓" if r["ppl_change"] < 0 else "↑"
61
+ line = (f" {r['domain']:<10s} {r['ppl_before']:>8.2f} {r['ppl_after']:>8.2f} "
62
+ f"{r['ppl_change']:>+7.2f}{delta_sym}")
63
+ if include_dense and "ppl_dense" in r:
64
+ diff_sym = "✓" if r["moe_vs_dense"] <= 0 else "✗"
65
+ line += f" {r['ppl_dense']:>8.2f} {r['moe_vs_dense']:>+9.2f}{diff_sym}"
66
+ print(line)
67
+ print("=" * 75 + "\n")
68
+
69
+
70
+ # Dashboard artifacts
71
+ def save_dashboard(cfg: ProjectConfig, rows: List[Dict], before_results: Dict, after_results: Dict, after_spec: Dict, history: Dict, params: Dict, dense_results: Optional[Dict] = None):
72
+ out = Path(cfg.artifact_dir)
73
+ out.mkdir(parents=True, exist_ok=True)
74
+
75
+ # ── CSV ──
76
+ import csv
77
+ csv_path = out / "before_after.csv"
78
+ if not rows:
79
+ print("[WARNING] No comparison rows to save — skipping CSV.")
80
+ else:
81
+ with open(csv_path, "w", newline="") as f:
82
+ writer = csv.DictWriter(f, fieldnames=rows[0].keys())
83
+ writer.writeheader()
84
+ writer.writerows([{k: round(v, 4) if isinstance(v, float) else v
85
+ for k, v in r.items()} for r in rows])
86
+ print(f" CSV saved to : {csv_path}")
87
+
88
+ # ── JSON ──
89
+ json_path = out / "eval_results.json"
90
+ with open(json_path, "w") as f:
91
+ json.dump(to_serializable({
92
+ "before": before_results,
93
+ "after": after_results,
94
+ "dense": dense_results,
95
+ "params": params,
96
+ "after_spec": {k: v for k, v in after_spec.items() if k != "affinity"},
97
+ }), f, indent=2)
98
+ print(f" JSON saved to : {json_path}")
99
+
100
+ spec_path = out / "specialization.json"
101
+ with open(spec_path, "w") as f:
102
+ json.dump(to_serializable(after_spec), f, indent=2)
103
+ print(f" Spec JSON saved to: {spec_path}")
104
+
105
+ hist_path = out / "training_history.json"
106
+ with open(hist_path, "w") as f:
107
+ json.dump(to_serializable(history), f, indent=2)
108
+ print(f" History saved to : {hist_path}")
109
+
110
+ # ── Plots ──
111
+ if HAS_MATPLOTLIB:
112
+ # Per-Domain Comparison
113
+ domain_plot = DomainScorePlot()
114
+ domain_plot.plot_comparison(rows, include_dense=dense_results is not None)
115
+ domain_plot.save(out / "plot_domain_scores.png")
116
+ domain_plot.close()
117
+ print(f" Domain plot saved : {out / 'plot_domain_scores.png'}")
118
+
119
+ # Training Convergence
120
+ if history and "train_loss" in history:
121
+ train_plot = TrainingConvergencePlot()
122
+ train_plot.plot_history(history)
123
+ train_plot.save(out / "plot_training_loss.png")
124
+ train_plot.close()
125
+ print(f" Loss plot saved : {out / 'plot_training_loss.png'}")
126
+
127
+ # Expert Routing Heatmap
128
+ routing_plot = ExpertRoutingHeatmap()
129
+ routing_plot.plot_routing(after_spec)
130
+ routing_plot.save(out / "plot_expert_routing.png")
131
+ routing_plot.close()
132
+ print(f" Heatmap saved : {out / 'plot_expert_routing.png'}")
133
+ else:
134
+ print("[WARNING] Matplotlib not available. Skipping plot generation.")
135
+
136
+ # ── Markdown summary ──
137
+ _save_dashboard_md(out / "dashboard_summary.md", cfg, rows, params,
138
+ after_spec, history, include_dense=dense_results is not None)
139
+
140
+
141
+ def _save_dashboard_md(path: Path, cfg: ProjectConfig, rows: List[Dict], params: Dict, spec: Dict, history: Dict, include_dense: bool):
142
+ lines = [
143
+ "# Keiro — Experiment Summary\n",
144
+ f"**Base model**: `{cfg.base_model}` ",
145
+ f"**Experts**: {cfg.routing.num_experts} × {cfg.expert.expert_type} "
146
+ f"(rank {cfg.expert.lora_rank}) ",
147
+ f"**Top-k**: {cfg.routing.top_k} ",
148
+ f"**Epochs**: {cfg.training.num_epochs} ",
149
+ f"**Seq length**: {cfg.training.max_seq_len} ",
150
+ f"**Samples/domain**: {cfg.data.samples_per_domain} ",
151
+ f"**LR**: {cfg.training.lr} ",
152
+ f"**Batch size**: {cfg.training.batch_size} × {cfg.training.gradient_accumulation_steps} "
153
+ f"(eff={cfg.training.batch_size * cfg.training.gradient_accumulation_steps}) \n",
154
+ ]
155
+
156
+ train_loss = history.get("train_loss", [])
157
+ val_losses = history.get("val_loss", [])
158
+ if train_loss:
159
+ lines.append("## Training\n")
160
+ lines.append("| Metric | Value |")
161
+ lines.append("|--------|-------|")
162
+ lines.append(f"| Total steps | {len(train_loss)} |")
163
+ lines.append(f"| Final train loss | {train_loss[-1]:.4f} |")
164
+ if val_losses:
165
+ best_ep = val_losses.index(min(val_losses)) + 1
166
+ lines.append(f"| Best val loss | {min(val_losses):.4f} (epoch {best_ep}) |")
167
+ if len(val_losses) > 1 and val_losses[-1] > min(val_losses):
168
+ lines.append(f"| Early stopping | Yes (restored epoch {best_ep}) |")
169
+ lines.append("")
170
+
171
+ lines.append("## Parameter Efficiency\n")
172
+ lines.append("| Metric | Value |")
173
+ lines.append("|--------|-------|")
174
+ lines.append(f"| Total params | {params['total']:,} |")
175
+ lines.append(f"| Trainable | {params['trainable']:,} ({params['trainable_pct']:.2f}%) |")
176
+ lines.append(f"| Router | {params['router']:,} |")
177
+ lines.append(f"| Experts | {params['experts']:,} |\n")
178
+
179
+ lines.append("## Per-Domain Results\n")
180
+ if include_dense:
181
+ lines.append("| Domain | Before | After | delta | % Change | Dense | MoE−Dense |")
182
+ lines.append("|--------|--------|-------|---|----------|-------|-----------|")
183
+ else:
184
+ lines.append("| Domain | Before | After | delta | % Change |")
185
+ lines.append("|--------|--------|-------|---|----------|")
186
+
187
+ for r in rows:
188
+ sym = "↓" if r["ppl_change"] < 0 else "↑"
189
+ pct = (r["ppl_change"] / r["ppl_before"]) * 100 if r["ppl_before"] > 0 else 0
190
+ line = (f"| {r['domain']} | {r['ppl_before']:.2f} | {r['ppl_after']:.2f} | "
191
+ f"{r['ppl_change']:+.2f}{sym} | {pct:+.1f}% |")
192
+ if include_dense and "ppl_dense" in r:
193
+ line += f" {r['ppl_dense']:.2f} | {r['moe_vs_dense']:+.2f} |"
194
+ lines.append(line)
195
+ lines.append("")
196
+
197
+ div = spec.get("divergence")
198
+ if div is not None:
199
+ if isinstance(div, torch.Tensor):
200
+ div = div.numpy()
201
+ n = div.shape[0]
202
+ upper = [div[i, j] for i in range(n) for j in range(i + 1, n)]
203
+ if upper:
204
+ lines.append("## Routing Divergence\n")
205
+ lines.append(f"Mean inter-domain JS divergence: **{np.mean(upper):.4f}** ")
206
+ lines.append("(Higher = more distinct routing per domain)\n")
207
+
208
+ lines.append("### Visualizations\n")
209
+ lines.append("![Domain Scores](plot_domain_scores.png)")
210
+ lines.append("![Training Loss](plot_training_loss.png)")
211
+ lines.append("![Expert Routing](plot_expert_routing.png)")
212
+ lines.append("")
213
+
214
+ path.write_text("\n".join(lines), encoding="utf-8")
215
+ print(f" Summary saved to : {path}")
216
+
217
+
218
+ # Qualitative text generation
219
+ from .prompts import DOMAIN_PROMPTS
220
+
221
+
222
+ @torch.no_grad()
223
+ def generate_qualitative_samples(model, tokenizer, device, max_new_tokens: int = 80) -> str:
224
+ model.eval()
225
+ lines = []
226
+
227
+ for domain, prompts in DOMAIN_PROMPTS.items():
228
+ lines.append(f"{'='*70}")
229
+ lines.append(f" DOMAIN: {domain.upper()}")
230
+ lines.append(f"{'='*70}")
231
+
232
+ for i, prompt in enumerate(prompts, 1):
233
+ inputs = tokenizer(
234
+ prompt, return_tensors="pt", truncation=True, max_length=256
235
+ ).to(device)
236
+
237
+ outputs = model.generate(
238
+ **inputs,
239
+ max_new_tokens=max_new_tokens,
240
+ do_sample=False,
241
+ pad_token_id=tokenizer.eos_token_id,
242
+ repetition_penalty=1.2,
243
+ )
244
+
245
+ if "attention_mask" in inputs:
246
+ prompt_len = int(inputs["attention_mask"][0].sum().item())
247
+ else:
248
+ prompt_len = inputs["input_ids"].shape[1]
249
+ completion = tokenizer.decode(outputs[0, prompt_len:], skip_special_tokens=True)
250
+
251
+ lines.append(f"\n Prompt {i}:")
252
+ lines.append(f" {'─'*60}")
253
+ for line in prompt.split('\n'):
254
+ lines.append(f" {line}")
255
+ lines.append("\n Completion:")
256
+ lines.append(f" {'─'*60}")
257
+ for line in completion.strip().split('\n'):
258
+ lines.append(f" {line}")
259
+ lines.append("")
260
+
261
+ return "\n".join(lines)
262
+
263
+
264
+ # Multi-seed experiment
265
+ def run_multi_seed(cfg: ProjectConfig, train_loaders: Dict, eval_loaders: Dict, device: torch.device):
266
+ from transformers import AutoModelForCausalLM
267
+ seeds = parse_seed_list(cfg.multi_seed_list)
268
+ all_results = []
269
+
270
+ for seed in seeds:
271
+ print(f"\n{'='*50}")
272
+ print(f"Multi-seed run: seed={seed}")
273
+ print(f"{'='*50}")
274
+ set_seed(seed)
275
+
276
+ model = AutoModelForCausalLM.from_pretrained(cfg.base_model).to(device)
277
+ model = inject_moe_layers(model, cfg.expert, cfg.routing)
278
+ try:
279
+ model.gradient_checkpointing_enable(
280
+ gradient_checkpointing_kwargs={"use_reentrant": False}
281
+ )
282
+ except TypeError:
283
+ model.gradient_checkpointing_enable()
284
+
285
+ mixed_train = build_mixed_loader(train_loaders, cfg.training.batch_size, seed)
286
+ mixed_eval = build_mixed_loader(eval_loaders, cfg.training.batch_size, seed)
287
+
288
+ train_cfg = copy.copy(cfg.training)
289
+ train_cfg.num_epochs = cfg.multi_seed_epochs
290
+ train_cfg.save_every_steps = 0
291
+
292
+ trainer = MoETrainer(model, mixed_train, mixed_eval, train_cfg)
293
+ trainer.train()
294
+
295
+ results = evaluate_per_domain(model, eval_loaders, device)
296
+ all_results.append(results)
297
+
298
+ del model, trainer
299
+ if torch.cuda.is_available():
300
+ torch.cuda.empty_cache()
301
+
302
+ _print_multi_seed_summary(seeds, all_results)
303
+
304
+ out = Path(cfg.artifact_dir) / "multi_seed_results.json"
305
+ with open(out, "w") as f:
306
+ json.dump(to_serializable({"seeds": seeds, "results": all_results}), f, indent=2)
307
+ print(f"Multi-seed results saved: {out}")
308
+
309
+
310
+ def _print_multi_seed_summary(seeds: List[int], all_results: List[Dict]):
311
+ domains = list(all_results[0].keys())
312
+
313
+ print(f"\n{'='*60}")
314
+ print(f" Multi-Seed Summary ({len(seeds)} seeds: {seeds})")
315
+ print(f"{'='*60}")
316
+ print(f" {'Domain':<10s} {'Mean PPL':>10s} {'Std PPL':>10s} {'Min':>8s} {'Max':>8s}")
317
+ print(f" {'-'*48}")
318
+
319
+ for domain in domains:
320
+ ppls = [r[domain]["perplexity"] for r in all_results]
321
+ mean = sum(ppls) / len(ppls)
322
+ std = (sum((p - mean)**2 for p in ppls) / max(len(ppls) - 1, 1)) ** 0.5
323
+ print(f" {domain:<10s} {mean:>10.2f} {std:>10.2f} {min(ppls):>8.2f} {max(ppls):>8.2f}")
324
+ print(f"{'='*60}\n")
architecture/sparse_moe/routing.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, NamedTuple, Optional
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ import torch.nn.functional as F
8
+
9
+ from .config import RouterConfig
10
+
11
+
12
+ # Router output container
13
+ class RoutingOutput(NamedTuple):
14
+
15
+ gate_probs: torch.Tensor
16
+ top_k_indices: torch.Tensor
17
+ top_k_weights: torch.Tensor
18
+ aux_loss: torch.Tensor
19
+ stats: Dict[str, float]
20
+
21
+
22
+ # Linear Router
23
+ class LinearRouter(nn.Module):
24
+
25
+ def __init__(self, d_model: int, config: RouterConfig):
26
+ super().__init__()
27
+ self.num_experts = config.num_experts
28
+ self.top_k = min(config.top_k, config.num_experts)
29
+ self.noise_std = config.noise_std
30
+ self.z_loss_coeff = config.z_loss_coeff
31
+ self.loss_type = config.loss_type
32
+ self.entropy_reg = config.entropy_reg
33
+
34
+ # Gating projection: logits = x @ W + b
35
+ self.gate = nn.Linear(d_model, config.num_experts, bias=True)
36
+ nn.init.xavier_uniform_(self.gate.weight)
37
+ nn.init.zeros_(self.gate.bias)
38
+
39
+ def forward(self, x: torch.Tensor, attention_mask: Optional[torch.Tensor] = None) -> RoutingOutput:
40
+ B, S, D = x.shape
41
+ token_mask = self._normalise_attention_mask(attention_mask, B, S, x.device)
42
+
43
+ # ── Logits ──
44
+ clean_logits = self.gate(x)
45
+ logits = clean_logits
46
+
47
+ # Exploration noise (training only)
48
+ if self.training and self.noise_std > 0:
49
+ logits = logits + torch.randn_like(logits) * self.noise_std
50
+
51
+ # ── Probabilities ──
52
+ orig_dtype = logits.dtype
53
+ probs = F.softmax(logits.float(), dim=-1)
54
+
55
+ # ── Top-k selection ──
56
+ top_k_weights, top_k_indices = torch.topk(probs, self.top_k, dim=-1)
57
+
58
+ if self.top_k > 1:
59
+ top_k_weights = top_k_weights / (
60
+ top_k_weights.sum(dim=-1, keepdim=True) + 1e-9
61
+ )
62
+
63
+ # Sparse gate tensor (for downstream masking)
64
+ gate_probs = torch.zeros_like(probs)
65
+ gate_probs.scatter_(-1, top_k_indices, top_k_weights)
66
+
67
+ # Cast weights back to original dtype for downstream expert dispatch
68
+ top_k_weights = top_k_weights.to(orig_dtype)
69
+ gate_probs = gate_probs.to(orig_dtype)
70
+
71
+ # Auxiliary losses
72
+ aux_loss = self._load_balance_loss(probs, top_k_indices, token_mask)
73
+ aux_loss = aux_loss + self._z_loss(clean_logits, token_mask)
74
+ if self.entropy_reg > 0:
75
+ aux_loss = aux_loss + self._entropy_loss(probs, token_mask)
76
+
77
+ valid_mask = token_mask.unsqueeze(-1)
78
+ top_k_weights = top_k_weights.masked_fill(~valid_mask, 0.0)
79
+
80
+ # Stats (detached, for logging only)
81
+ with torch.no_grad():
82
+ f = self._expert_fractions(top_k_indices, token_mask)
83
+ # Exact entropy: use torch.where to avoid log(0)
84
+ log_probs = torch.where(probs > 0, probs.log(), torch.zeros_like(probs))
85
+ token_entropy = -(probs * log_probs).sum(dim=-1)
86
+ valid_tokens = int(token_mask.sum().item())
87
+ if valid_tokens > 0:
88
+ entropy = token_entropy[token_mask].mean()
89
+ else:
90
+ entropy = probs.new_zeros(())
91
+ stats = {
92
+ "routing_entropy": entropy.item(),
93
+ "expert_fractions": f.cpu().tolist(),
94
+ "aux_loss": aux_loss.item(),
95
+ "num_tokens": valid_tokens,
96
+ "num_assignments": valid_tokens * self.top_k,
97
+ }
98
+
99
+ gate_probs = gate_probs.masked_fill(~valid_mask, 0.0)
100
+ return RoutingOutput(gate_probs, top_k_indices, top_k_weights, aux_loss, stats)
101
+
102
+ # Loss helpers
103
+ def _normalise_attention_mask(self, attention_mask: Optional[torch.Tensor], batch_size: int, seq_len: int, device: torch.device) -> torch.Tensor:
104
+ if attention_mask is None:
105
+ return torch.ones(batch_size, seq_len, dtype=torch.bool, device=device)
106
+
107
+ if attention_mask.dim() > 2:
108
+ if attention_mask.dim() == 4:
109
+ # Handle 4D causal masks e.g. [B, 1, S, S]
110
+ attention_mask = attention_mask[:, 0, -1, :]
111
+ elif attention_mask.dim() == 3:
112
+ attention_mask = attention_mask[:, -1, :]
113
+ else:
114
+ attention_mask = attention_mask.reshape(batch_size, -1)
115
+ if attention_mask.shape[1] != seq_len:
116
+ if attention_mask.shape[1] < seq_len:
117
+ raise ValueError(
118
+ f"attention_mask length {attention_mask.shape[1]} does not match "
119
+ f"sequence length {seq_len}"
120
+ )
121
+ attention_mask = attention_mask[:, -seq_len:]
122
+ return attention_mask.to(device=device, dtype=torch.bool)
123
+
124
+ def _expert_fractions(self, top_k_indices: torch.Tensor, token_mask: Optional[torch.Tensor] = None) -> torch.Tensor:
125
+ flat = top_k_indices.reshape(-1)
126
+ if token_mask is not None:
127
+ assignment_mask = token_mask.reshape(-1, 1).expand(-1, self.top_k).reshape(-1)
128
+ flat = flat[assignment_mask]
129
+ if flat.numel() == 0:
130
+ return torch.zeros(self.num_experts, device=top_k_indices.device)
131
+ counts = torch.bincount(flat, minlength=self.num_experts).float()
132
+ return counts / counts.sum().clamp(min=1)
133
+
134
+ def _load_balance_loss(self, probs: torch.Tensor, top_k_indices: torch.Tensor, token_mask: Optional[torch.Tensor] = None) -> torch.Tensor:
135
+ f = self._expert_fractions(top_k_indices, token_mask)
136
+ P = self._mean_probs(probs, token_mask)
137
+ return self.num_experts * (f * P).sum()
138
+
139
+ def _mean_probs(self, probs: torch.Tensor, token_mask: Optional[torch.Tensor] = None) -> torch.Tensor:
140
+ if token_mask is None:
141
+ return probs.mean(dim=(0, 1))
142
+
143
+ flat_mask = token_mask.reshape(-1)
144
+ flat_probs = probs.reshape(-1, probs.shape[-1])
145
+ n_valid = flat_mask.sum()
146
+
147
+ # Avoid graph breaks from dynamic control flow
148
+ sum_probs = (flat_probs * flat_mask.unsqueeze(-1)).sum(dim=0)
149
+ return sum_probs / n_valid.clamp(min=1)
150
+
151
+ def _entropy_loss(self, probs: torch.Tensor, token_mask: Optional[torch.Tensor] = None) -> torch.Tensor:
152
+ P_mean = self._mean_probs(probs, token_mask)
153
+ entropy = -(P_mean * (P_mean + 1e-9).log()).sum()
154
+ return -self.entropy_reg * entropy
155
+
156
+ def _z_loss(self, logits: torch.Tensor, token_mask: Optional[torch.Tensor] = None) -> torch.Tensor:
157
+ if token_mask is None:
158
+ return self.z_loss_coeff * (logits ** 2).mean()
159
+
160
+ flat_mask = token_mask.reshape(-1)
161
+ flat_logits = logits.reshape(-1, logits.shape[-1])
162
+ n_valid = flat_mask.sum()
163
+
164
+ # Avoid graph breaks from dynamic control flow
165
+ sum_z = ((flat_logits ** 2) * flat_mask.unsqueeze(-1)).sum()
166
+ return self.z_loss_coeff * (sum_z / n_valid.clamp(min=1))
167
+
168
+ def extra_repr(self) -> str:
169
+ return (f"experts={self.num_experts}, top_k={self.top_k}, "
170
+ f"loss={self.loss_type}, noise={self.noise_std}, "
171
+ f"entropy_reg={self.entropy_reg}")
architecture/sparse_moe/stage_runner.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import glob
3
+ import re
4
+ from typing import Tuple, Optional
5
+
6
+ def find_latest_checkpoint(checkpoint_dir: str) -> Tuple[Optional[int], Optional[str]]:
7
+ if not os.path.isdir(checkpoint_dir):
8
+ return None, None
9
+
10
+ epoch_files = glob.glob(os.path.join(checkpoint_dir, "moe-epoch_*.pt"))
11
+ if not epoch_files:
12
+ return None, None
13
+
14
+ best_epoch = -1
15
+ best_file = None
16
+
17
+ for f in epoch_files:
18
+ m = re.search(r"moe-epoch_(\d+)\.pt", f)
19
+ if m:
20
+ ep = int(m.group(1))
21
+ if ep > best_epoch:
22
+ best_epoch = ep
23
+ best_file = f
24
+
25
+ if best_epoch >= 0:
26
+ return best_epoch, best_file
27
+ return None, None
28
+
29
+ def download_stage_from_hub(repo_id: str, remote_path: str, local_dir: str, token: str = None) -> bool:
30
+ from huggingface_hub import snapshot_download
31
+ token = token or os.environ.get("HF_TOKEN")
32
+ try:
33
+ snapshot_download(
34
+ repo_id=repo_id,
35
+ repo_type="dataset",
36
+ allow_patterns=f"{remote_path}/moe-*.pt",
37
+ local_dir=local_dir,
38
+ local_dir_use_symlinks=False,
39
+ token=token
40
+ )
41
+ return True
42
+ except Exception as e:
43
+ print(f"[StageRunner] Could not download {remote_path} from Hub: {e}")
44
+ return False
45
+
46
+ def get_resume_epoch(checkpoint_dir: str, hub_repo_id: str, stage_name: str) -> Optional[int]:
47
+ epoch, local_path = find_latest_checkpoint(checkpoint_dir)
48
+
49
+ if epoch is not None:
50
+ print(f"[StageRunner] Found local checkpoint at epoch {epoch}")
51
+ return epoch
52
+
53
+ print(f"[StageRunner] No local checkpoint in {checkpoint_dir}. Checking Hub...")
54
+
55
+ remote_path = f"{stage_name}"
56
+ success = download_stage_from_hub(hub_repo_id, remote_path, ".", token=None)
57
+
58
+ if success:
59
+ epoch, local_path = find_latest_checkpoint(checkpoint_dir)
60
+ if epoch is not None:
61
+ print(f"[StageRunner] Downloaded checkpoint from Hub and resuming from epoch {epoch}")
62
+ return epoch
63
+
64
+ print("[StageRunner] No checkpoints found locally or on Hub. Starting from scratch.")
65
+ return None
architecture/sparse_moe/sys_profiler.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import json
5
+ import threading
6
+ import subprocess
7
+ from datetime import datetime
8
+ from typing import Dict, List, Optional, Any
9
+
10
+ import torch
11
+
12
+
13
+ class SystemEnvironment:
14
+
15
+ @staticmethod
16
+ def get_nvidia_smi_info() -> str:
17
+ try:
18
+ result = subprocess.run(
19
+ ["nvidia-smi"],
20
+ capture_output=True,
21
+ text=True,
22
+ timeout=5
23
+ )
24
+ return result.stdout if result.returncode == 0 else "nvidia-smi returned an error."
25
+ except Exception as e:
26
+ return f"nvidia-smi not available or failed: {e}"
27
+
28
+ @staticmethod
29
+ def get_pytorch_info() -> Dict[str, Any]:
30
+ return {
31
+ "python_version": sys.version,
32
+ "torch_version": torch.__version__,
33
+ "cuda_available": torch.cuda.is_available(),
34
+ "cuda_version": torch.version.cuda if torch.cuda.is_available() else None,
35
+ "cudnn_version": torch.backends.cudnn.version() if torch.cuda.is_available() else None,
36
+ "alloc_conf": os.environ.get("PYTORCH_CUDA_ALLOC_CONF", "Not Set"),
37
+ }
38
+
39
+ @staticmethod
40
+ def generate_system_report() -> str:
41
+ report = []
42
+ report.append(f"Date: {datetime.utcnow().strftime('%a %b %d %H:%M:%S UTC %Y')}")
43
+ report.append(f"Sys Platform: {sys.platform}")
44
+ report.append("\n=== PyTorch Info ===")
45
+ pt_info = SystemEnvironment.get_pytorch_info()
46
+ for k, v in pt_info.items():
47
+ report.append(f"{k}: {v}")
48
+
49
+ report.append("\n=== NVIDIA-SMI ===")
50
+ report.append(SystemEnvironment.get_nvidia_smi_info())
51
+
52
+ return "\n".join(report)
53
+
54
+
55
+ class ResourceTracker:
56
+
57
+ def __init__(self, polling_interval: float = 0.5, use_cuda_native: bool = True):
58
+ self.polling_interval = polling_interval
59
+ self.use_cuda_native = use_cuda_native
60
+
61
+ # State
62
+ self.is_running = False
63
+ self._thread: Optional[threading.Thread] = None
64
+
65
+ # Data
66
+ self.timestamps: List[float] = []
67
+ self.gpu_utilization: List[float] = []
68
+ self.memory_allocated_mb: List[float] = []
69
+ self.memory_reserved_mb: List[float] = []
70
+
71
+ self._start_time: float = 0.0
72
+
73
+ def _poll_metrics(self):
74
+ while self.is_running:
75
+ current_time = time.time() - self._start_time
76
+ self.timestamps.append(current_time)
77
+
78
+ # 1. PyTorch CUDA Native memory (very exact per-process)
79
+ if torch.cuda.is_available():
80
+ alloc_mb = torch.cuda.memory_allocated() / (1024 ** 2)
81
+ resrv_mb = torch.cuda.memory_reserved() / (1024 ** 2)
82
+ else:
83
+ alloc_mb, resrv_mb = 0.0, 0.0
84
+
85
+ self.memory_allocated_mb.append(alloc_mb)
86
+ self.memory_reserved_mb.append(resrv_mb)
87
+
88
+ # 2. System Level GPU Utilization (via fast nvidia-smi call)
89
+ util = 0.0
90
+ if torch.cuda.is_available():
91
+ # Query utilization
92
+ res = subprocess.run(
93
+ ["nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"],
94
+ capture_output=True, text=True
95
+ )
96
+ if res.returncode == 0:
97
+ lines = res.stdout.strip().split('\n')
98
+ if lines and lines[0].isdigit():
99
+ util = float(lines[0])
100
+ except Exception:
101
+ pass
102
+ self.gpu_utilization.append(util)
103
+
104
+ time.sleep(self.polling_interval)
105
+
106
+ def start(self):
107
+ if self.is_running:
108
+ return
109
+
110
+ self.is_running = True
111
+ self.timestamps.clear()
112
+ self.gpu_utilization.clear()
113
+ self.memory_allocated_mb.clear()
114
+ self.memory_reserved_mb.clear()
115
+
116
+ if torch.cuda.is_available():
117
+ torch.cuda.reset_peak_memory_stats()
118
+
119
+ self._start_time = time.time()
120
+ self._thread = threading.Thread(target=self._poll_metrics, daemon=True)
121
+ self._thread.start()
122
+
123
+ def stop(self) -> Dict[str, List[float]]:
124
+ self.is_running = False
125
+ if self._thread is not None:
126
+ self._thread.join(timeout=self.polling_interval * 2)
127
+
128
+ return self.get_results()
129
+
130
+ def get_results(self) -> Dict[str, List[float]]:
131
+ return {
132
+ "timestamps_sec": list(self.timestamps),
133
+ "gpu_utilization_pct": list(self.gpu_utilization),
134
+ "memory_allocated_mb": list(self.memory_allocated_mb),
135
+ "memory_reserved_mb": list(self.memory_reserved_mb),
136
+ }
137
+
138
+ def save_tabular_log(self, filepath: str, label: str = "Workload"):
139
+ with open(filepath, "w") as f:
140
+ f.write(f"=== Resource Tracking: {label} ===\n")
141
+ f.write(f"Polling Interval: {self.polling_interval}s\n")
142
+ f.write("-" * 65 + "\n")
143
+ f.write(f"{'Time(s)':>10} | {'GPU Util(%)':>15} | {'Allocated(MB)':>15} | {'Reserved(MB)':>15}\n")
144
+ f.write("-" * 65 + "\n")
145
+
146
+ for t, u, a, r in zip(self.timestamps, self.gpu_utilization, self.memory_allocated_mb, self.memory_reserved_mb):
147
+ f.write(f"{t:>10.2f} | {u:>15.1f} | {a:>15.1f} | {r:>15.1f}\n")
148
+
149
+ f.write("-" * 65 + "\n")
150
+ if self.memory_allocated_mb:
151
+ f.write(f"Peak Allocated: {max(self.memory_allocated_mb):.1f} MB\n")
152
+ if self.memory_reserved_mb:
153
+ f.write(f"Peak Reserved: {max(self.memory_reserved_mb):.1f} MB\n")
154
+
155
+ if __name__ == "__main__":
156
+ # Smoke test structure
157
+ print(SystemEnvironment.generate_system_report())
architecture/sparse_moe/trainer.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ from torch.utils.data import DataLoader
10
+
11
+ from .config import TrainingConfig
12
+ from .layers import SparseMoELayer
13
+ from .utils import build_labels
14
+
15
+
16
+
17
+ # LR schedule
18
+ def _cosine_with_warmup(step: int, warmup: int, total: int) -> float:
19
+ if step < warmup:
20
+ return step / max(warmup, 1)
21
+ progress = (step - warmup) / max(total - warmup, 1)
22
+ return 0.5 * (1.0 + math.cos(math.pi * progress))
23
+
24
+
25
+ def _build_amp(device: torch.device, fp16: bool) -> tuple[bool, torch.dtype, torch.amp.GradScaler]:
26
+ use_amp = fp16 and device.type == "cuda"
27
+ amp_dtype = torch.float16
28
+ if use_amp and torch.cuda.is_bf16_supported():
29
+ amp_dtype = torch.bfloat16
30
+ scaler = torch.amp.GradScaler(device.type, enabled=use_amp and amp_dtype == torch.float16)
31
+ return use_amp, amp_dtype, scaler
32
+
33
+
34
+ # Trainer
35
+ class MoETrainer:
36
+
37
+ def __init__(
38
+ self,
39
+ model: nn.Module,
40
+ train_loader: DataLoader,
41
+ val_loader: Optional[DataLoader],
42
+ config: TrainingConfig,
43
+ ):
44
+ self.model = model
45
+ self.train_loader = train_loader
46
+ self.val_loader = val_loader
47
+ self.config = config
48
+ self.start_epoch = 1
49
+
50
+ # Device
51
+ self.device = next(model.parameters()).device
52
+
53
+ trainable = [p for p in model.parameters() if p.requires_grad]
54
+ if not trainable:
55
+ raise ValueError(
56
+ "MoETrainer received a model with no trainable parameters. "
57
+ "Check that MoE injection succeeded for this architecture."
58
+ )
59
+ self.optimizer = torch.optim.AdamW(trainable, lr=config.lr, weight_decay=0.01)
60
+
61
+ self.use_amp, self._amp_dtype, self.scaler = _build_amp(self.device, config.fp16)
62
+ if self.use_amp and self._amp_dtype == torch.bfloat16:
63
+ print("Using BF16 mixed precision (Ampere+/Blackwell detected)")
64
+
65
+ if self.device.type == "cuda":
66
+ torch.set_float32_matmul_precision("high")
67
+ torch.backends.cudnn.benchmark = True
68
+ print("Enabled TF32 matmuls + cuDNN benchmark")
69
+
70
+ self._moe_layers: list[SparseMoELayer] = [
71
+ m for m in model.modules() if isinstance(m, SparseMoELayer)
72
+ ]
73
+
74
+ # Step tracking
75
+ self.global_step = 0
76
+ self._total_steps = self._estimate_total_steps()
77
+
78
+ self.scheduler = torch.optim.lr_scheduler.LambdaLR(
79
+ self.optimizer,
80
+ lr_lambda=lambda step: _cosine_with_warmup(
81
+ step, config.warmup_steps, self._total_steps
82
+ ),
83
+ )
84
+
85
+ # No external logging — all metrics tracked in the returned history dict
86
+
87
+ print(
88
+ f"MoETrainer: {sum(p.numel() for p in trainable):,} trainable params, "
89
+ f"AMP={'on' if self.use_amp else 'off'}, "
90
+ f"effective_batch={config.batch_size * config.gradient_accumulation_steps}, "
91
+ f"total_steps≈{self._total_steps}"
92
+ )
93
+
94
+ # Initialise router entropy_reg to step 0 value
95
+ self._anneal_entropy()
96
+
97
+ # Public API
98
+ def train(self) -> Dict[str, List[float]]:
99
+ history: Dict[str, List[float]] = {
100
+ "train_loss": [], "val_loss": [], "aux_loss": [], "lr": [],
101
+ "epoch_boundaries": [], # global step at start of each epoch
102
+ }
103
+
104
+ best_val_loss = float("inf")
105
+ patience = 1
106
+ patience_counter = 0
107
+ best_state = None
108
+
109
+ for epoch in range(self.start_epoch, self.config.num_epochs + 1):
110
+ print(f"═══ Epoch {epoch}/{self.config.num_epochs} ═══")
111
+ history["epoch_boundaries"].append(self.global_step)
112
+ self._train_epoch(history)
113
+ self.save_checkpoint(f"epoch_{epoch}")
114
+
115
+ if self.val_loader is not None:
116
+ val_loss = self.evaluate()
117
+ history["val_loss"].append(val_loss)
118
+ print(f" val_loss={val_loss:.4f}")
119
+
120
+ if val_loss < best_val_loss:
121
+ best_val_loss = val_loss
122
+ patience_counter = 0
123
+ # Save best trainable weights
124
+ best_state = {
125
+ name: param.clone()
126
+ for name, param in self.model.named_parameters()
127
+ if param.requires_grad
128
+ }
129
+ self.save_checkpoint("best")
130
+ print(f" New best val_loss={best_val_loss:.4f} (Saved moe-best.pt)")
131
+ else:
132
+ patience_counter += 1
133
+ print(
134
+ f" val_loss did not improve "
135
+ f"(best={best_val_loss:.4f}, patience={patience_counter}/{patience})"
136
+ )
137
+ if patience_counter >= patience:
138
+ print(" Early stopping triggered — restoring best weights.")
139
+ break
140
+
141
+ # After loop completion, ensure best state is restored
142
+ if best_state is not None:
143
+ for name, param in self.model.named_parameters():
144
+ if name in best_state:
145
+ param.data.copy_(best_state[name])
146
+
147
+ print("Training complete.")
148
+ return history
149
+
150
+ @torch.no_grad()
151
+ def evaluate(self) -> float:
152
+ if self.val_loader is None:
153
+ return float("nan")
154
+
155
+ self.model.eval()
156
+ total_loss, total_tokens = 0.0, 0
157
+
158
+ for batch in self.val_loader:
159
+ batch = {k: v.to(self.device, non_blocking=True) for k, v in batch.items()}
160
+ labels = build_labels(batch)
161
+
162
+ with torch.amp.autocast(self.device.type, dtype=self._amp_dtype, enabled=self.use_amp):
163
+ outputs = self.model(**batch, labels=labels)
164
+
165
+ valid_tokens = int((labels[:, 1:] != -100).sum().item())
166
+ total_loss += outputs.loss.item() * max(valid_tokens, 1)
167
+ total_tokens += valid_tokens
168
+
169
+ self.model.train()
170
+ return total_loss / max(total_tokens, 1)
171
+
172
+ # Training epoch
173
+ def _train_epoch(self, history: Dict[str, List[float]]):
174
+ self.model.train()
175
+ accum_steps = self.config.gradient_accumulation_steps
176
+ epoch_loss = 0.0
177
+ epoch_steps = 0
178
+
179
+ self.optimizer.zero_grad(set_to_none=True)
180
+
181
+ for step_in_epoch, batch in enumerate(self.train_loader):
182
+ batch = {k: v.to(self.device, non_blocking=True) for k, v in batch.items()}
183
+ labels = build_labels(batch)
184
+
185
+ # ── Forward pass (AMP context) ──
186
+ with torch.amp.autocast(self.device.type, dtype=self._amp_dtype, enabled=self.use_amp):
187
+ outputs = self.model(**batch, labels=labels)
188
+ lm_loss = outputs.loss / accum_steps
189
+
190
+ # Auxiliary load-balance loss
191
+ aux_loss = self._collect_aux_loss() / accum_steps
192
+ total_loss = lm_loss + self.config.aux_loss_weight * aux_loss
193
+
194
+ # ── Backward pass ──
195
+ self.scaler.scale(total_loss).backward()
196
+
197
+ # ── Optimizer step (every accum_steps micro-batches) ──
198
+ is_accum_boundary = (step_in_epoch + 1) % accum_steps == 0 or (
199
+ step_in_epoch + 1
200
+ ) == len(self.train_loader)
201
+
202
+ # Accumulate per-micro-batch stats (not yet logged — done at step boundary)
203
+ raw_lm_loss = lm_loss.item() * accum_steps
204
+ raw_aux_loss = aux_loss.item() * accum_steps
205
+ epoch_loss += raw_lm_loss
206
+ epoch_steps += 1
207
+
208
+ if is_accum_boundary:
209
+ self.scaler.unscale_(self.optimizer)
210
+ nn.utils.clip_grad_norm_(
211
+ [p for p in self.model.parameters() if p.requires_grad],
212
+ self.config.max_grad_norm,
213
+ )
214
+
215
+ old_scale = self.scaler.get_scale()
216
+ self.scaler.step(self.optimizer)
217
+ self.scaler.update()
218
+
219
+ self.scheduler.step()
220
+
221
+ self.optimizer.zero_grad(set_to_none=True)
222
+ self.global_step += 1
223
+
224
+ # ── Entropy annealing ──
225
+ self._anneal_entropy()
226
+
227
+ history["lr"].append(current_lr)
228
+
229
+ if self.global_step % self.config.log_every_steps == 0:
230
+ avg = epoch_loss / max(epoch_steps, 1)
231
+ print(
232
+ f" step {self.global_step:>5d} | "
233
+ f"loss={raw_lm_loss:.4f} | "
234
+ f"aux={raw_aux_loss:.4f} | "
235
+ f"lr={current_lr:.2e} | "
236
+ f"avg={avg:.4f}"
237
+ + (f" | mem={torch.cuda.max_memory_allocated()/1e9:.1f}GB"
238
+ if torch.cuda.is_available() else "")
239
+ )
240
+
241
+ # ── Checkpoint (once per optimizer step) ──
242
+ if (
243
+ self.config.save_every_steps > 0
244
+ and self.global_step % self.config.save_every_steps == 0
245
+ ):
246
+ self.save_checkpoint(f"step-{self.global_step}")
247
+
248
+ # Helpers
249
+ def _collect_aux_loss(self) -> torch.Tensor:
250
+ total = torch.tensor(0.0, device=self.device)
251
+ count = 0
252
+ for moe in self._moe_layers:
253
+ loss = getattr(moe, "_last_aux_loss", None)
254
+ if loss is not None:
255
+ total = total + loss
256
+ count += 1
257
+ return total / max(count, 1)
258
+
259
+ def _anneal_entropy(self):
260
+ if self._total_steps <= 0:
261
+ return
262
+ progress = min(self.global_step / self._total_steps, 1.0)
263
+ new_reg = self.config.entropy_reg_start * 0.5 * (
264
+ 1.0 + math.cos(math.pi * progress)
265
+ )
266
+ for moe in self._moe_layers:
267
+ moe.routing.entropy_reg = new_reg
268
+
269
+ def _estimate_total_steps(self) -> int:
270
+ batches = len(self.train_loader)
271
+ accum = self.config.gradient_accumulation_steps
272
+ steps_per_epoch = max(math.ceil(batches / accum), 1)
273
+ return steps_per_epoch * self.config.num_epochs
274
+
275
+ # Checkpointing
276
+ def save_checkpoint(self, tag: str):
277
+ out_dir = Path(self.config.output_dir)
278
+ out_dir.mkdir(parents=True, exist_ok=True)
279
+
280
+ trainable_keys = {
281
+ k for k, p in self.model.named_parameters() if p.requires_grad
282
+ }
283
+ state = {
284
+ k: v for k, v in self.model.state_dict().items()
285
+ if k in trainable_keys
286
+ }
287
+
288
+ path = out_dir / f"moe-{tag}.pt"
289
+ torch.save({
290
+ "model_state": state,
291
+ "optimizer_state": self.optimizer.state_dict(),
292
+ "scheduler_state": self.scheduler.state_dict(),
293
+ "scaler_state": self.scaler.state_dict(),
294
+ "global_step": self.global_step,
295
+ }, path)
296
+ print(f"Checkpoint saved: {path}")
297
+
298
+ def load_checkpoint(self, path: str):
299
+ checkpoint = torch.load(path, map_location=self.device, weights_only=True)
300
+ state = checkpoint.get("model_state", checkpoint)
301
+
302
+ # Validate keys
303
+ trainable_keys = {
304
+ k for k, p in self.model.named_parameters() if p.requires_grad
305
+ }
306
+ incoming_keys = set(state.keys())
307
+
308
+ matched = trainable_keys & incoming_keys
309
+ missing = trainable_keys - incoming_keys
310
+ unexpected = incoming_keys - trainable_keys
311
+
312
+ incompatible = []
313
+ for key in matched:
314
+ checkpoint_shape = state[key].shape
315
+ model_shape = self.model.state_dict()[key].shape
316
+ if checkpoint_shape != model_shape:
317
+ incompatible.append(key)
318
+
319
+ if incompatible:
320
+ print(
321
+ f"[WARNING] Checkpoint {path} has {len(incompatible)} parameters with "
322
+ f"mismatching shapes (e.g. num_experts change). "
323
+ f"Skipping incompatible checkpoint — training will start from scratch."
324
+ )
325
+ return
326
+
327
+ if missing:
328
+ print(f"[WARNING] Missing keys in checkpoint: {missing}")
329
+ if unexpected:
330
+ print(f"[WARNING] Unexpected keys in checkpoint: {unexpected}")
331
+
332
+ current_state = self.model.state_dict()
333
+ for key in matched:
334
+ current_state[key].copy_(state[key])
335
+
336
+ # Restore optimizer + step
337
+ if "optimizer_state" in checkpoint:
338
+ try:
339
+ self.optimizer.load_state_dict(checkpoint["optimizer_state"])
340
+ except (ValueError, RuntimeError) as e:
341
+ print(f"[WARNING] Could not restore optimizer state: {e}. "
342
+ f"Optimizer will be re-initialized.")
343
+ if "scheduler_state" in checkpoint:
344
+ self.scheduler.load_state_dict(checkpoint["scheduler_state"])
345
+ if "scaler_state" in checkpoint:
346
+ self.scaler.load_state_dict(checkpoint["scaler_state"])
347
+ if "global_step" in checkpoint:
348
+ self.global_step = checkpoint["global_step"]
349
+ # Keep router entropy annealing aligned with the restored step.
350
+ self._anneal_entropy()
351
+
352
+ print(f"Checkpoint loaded: {path} (step {self.global_step})")
353
+
354
+ def _sync_scheduler_from_global_step(self):
355
+ multiplier = _cosine_with_warmup(
356
+ self.global_step,
357
+ self.config.warmup_steps,
358
+ self._total_steps,
359
+ )
360
+ self.scheduler.last_epoch = self.global_step
361
+ for group, base_lr in zip(self.optimizer.param_groups, self.scheduler.base_lrs):
362
+ group["lr"] = base_lr * multiplier
architecture/sparse_moe/utils.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from typing import Any, Dict, List
5
+
6
+ import torch
7
+ import numpy as np
8
+
9
+
10
+ # Label building
11
+ def build_labels(batch: Dict[str, torch.Tensor]) -> torch.Tensor:
12
+ labels = batch["input_ids"].clone() # (batch_size, seq_len)
13
+
14
+ if "attention_mask" in batch:
15
+ labels[batch["attention_mask"] == 0] = -100
16
+
17
+ return labels # (batch_size, seq_len)
18
+
19
+
20
+ # Reproducibility
21
+ def set_seed(seed: int) -> None:
22
+ random.seed(seed)
23
+ np.random.seed(seed)
24
+ torch.manual_seed(seed)
25
+ if torch.cuda.is_available():
26
+ torch.cuda.manual_seed_all(seed)
27
+ torch.backends.cudnn.benchmark = True
28
+
29
+
30
+ # Serialisation
31
+ def to_serializable(value: Any) -> Any:
32
+ if isinstance(value, torch.Tensor):
33
+ return value.detach().cpu().tolist()
34
+ if isinstance(value, np.ndarray):
35
+ return value.tolist()
36
+ if isinstance(value, np.integer):
37
+ return int(value)
38
+ if isinstance(value, np.floating):
39
+ return float(value)
40
+ if isinstance(value, dict):
41
+ return {k: to_serializable(v) for k, v in value.items()}
42
+ if isinstance(value, (list, tuple)):
43
+ return [to_serializable(v) for v in value]
44
+ if hasattr(value, "__dataclass_fields__"):
45
+ return {k: to_serializable(v) for k, v in value.__dict__.items()}
46
+ return value
47
+
48
+
49
+ # Multi-seed helpers
50
+ def parse_seed_list(raw: str) -> List[int]:
51
+ return [int(s.strip()) for s in raw.split(",") if s.strip()]
architecture/sparse_moe/visualization.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from typing import List, Optional, Tuple, Union, Dict, Any
5
+ from pathlib import Path
6
+ import numpy as np
7
+
8
+ try:
9
+ import matplotlib.pyplot as plt
10
+ import matplotlib.colors as mcolors
11
+ from matplotlib.gridspec import GridSpec
12
+ HAS_MATPLOTLIB = True
13
+ except ImportError:
14
+ HAS_MATPLOTLIB = False
15
+ warnings.warn(
16
+ "Matplotlib not installed. Visualization features unavailable. "
17
+ "Install with: pip install matplotlib"
18
+ )
19
+
20
+ def _check_matplotlib():
21
+ if not HAS_MATPLOTLIB:
22
+ raise RuntimeError(
23
+ "Matplotlib is required for visualization. "
24
+ "Install with: pip install matplotlib"
25
+ )
26
+
27
+
28
+ class KeiroPalette:
29
+
30
+ # Primary line colors
31
+ PRIMARY = {
32
+ "dense": "#E74C3C", # Red - Dense baseline ("Before")
33
+ "moe": "#3498DB", # Blue - Sparse MoE ("After")
34
+ "expert_avg": "#9B59B6",# Purple - Expert average
35
+ "memory": "#2ECC71", # Green - Memory bounds
36
+ }
37
+
38
+ # Generative expert spectrum for heatmap/routing
39
+ EXPERTS = [
40
+ "#3498DB", "#2980B9", "#1ABC9C", "#27AE60",
41
+ "#F39C12", "#D35400", "#E74C3C", "#8E44AD"
42
+ ]
43
+
44
+ BG_LIGHT = "#FAFAFA"
45
+ BG_DARK = "#1A1A2E"
46
+ GRID_LIGHT = "#E0E0E0"
47
+ GRID_DARK = "#2D2D44"
48
+
49
+
50
+ class BasePlot:
51
+
52
+ def __init__(self, figsize=(12, 8), dpi=150, theme="dark", title=None):
53
+ _check_matplotlib()
54
+ self.figsize = figsize
55
+ self.dpi = dpi
56
+ self.theme = theme
57
+ self.fig, self.ax = plt.subplots(figsize=figsize)
58
+ self._apply_theme()
59
+ if title:
60
+ self.ax.set_title(title, fontsize=16, fontweight='bold', pad=20)
61
+
62
+ def _apply_theme(self):
63
+ bg = KeiroPalette.BG_DARK if self.theme == "dark" else KeiroPalette.BG_LIGHT
64
+ grid = KeiroPalette.GRID_DARK if self.theme == "dark" else KeiroPalette.GRID_LIGHT
65
+ fg = 'white' if self.theme == "dark" else 'black'
66
+
67
+ self.fig.patch.set_facecolor(bg)
68
+ self.ax.set_facecolor(bg)
69
+ self.ax.tick_params(colors=fg)
70
+ self.ax.xaxis.label.set_color(fg)
71
+ self.ax.yaxis.label.set_color(fg)
72
+ self.ax.title.set_color(fg)
73
+ self.ax.grid(True, alpha=0.2, color=grid)
74
+ for spine in self.ax.spines.values():
75
+ spine.set_color(grid)
76
+
77
+ def save(self, filepath: Union[str, Path]):
78
+ filepath = Path(filepath)
79
+ filepath.parent.mkdir(parents=True, exist_ok=True)
80
+ self.fig.savefig(filepath, dpi=self.dpi, bbox_inches="tight", facecolor=self.fig.get_facecolor())
81
+
82
+ def close(self):
83
+ plt.close(self.fig)
84
+
85
+
86
+ class ResourceUtilizationPlot(BasePlot):
87
+
88
+ def __init__(self, title="Resource Utilization (Before vs After)", **kwargs):
89
+ super().__init__(title=title, **kwargs)
90
+ self.ax.set_xlabel("Time (seconds)", fontsize=12)
91
+ self.ax2 = self.ax.twinx()
92
+ self.ax.set_ylabel("Memory Allocated (MB)", fontsize=12)
93
+ self.ax2.set_ylabel("GPU Utilization (%)", fontsize=12)
94
+
95
+ if self.theme == "dark":
96
+ self.ax2.tick_params(colors='white')
97
+ self.ax2.yaxis.label.set_color('white')
98
+ for spine in self.ax2.spines.values():
99
+ spine.set_color(KeiroPalette.GRID_DARK)
100
+
101
+ def add_trace(self, time_sec: List[float], values: List[float], label: str, metric: str = "memory"):
102
+ color = KeiroPalette.PRIMARY["dense"] if "Before" in label or "Dense" in label else KeiroPalette.PRIMARY["moe"]
103
+ linestyle = "-" if metric == "memory" else "--"
104
+ axis = self.ax if metric == "memory" else self.ax2
105
+
106
+ axis.plot(
107
+ time_sec, values, label=label,
108
+ color=color, linestyle=linestyle, linewidth=2.5, alpha=0.8
109
+ )
110
+
111
+ def finalize(self):
112
+ lines1, labels1 = self.ax.get_legend_handles_labels()
113
+ lines2, labels2 = self.ax2.get_legend_handles_labels()
114
+ self.ax2.legend(lines1 + lines2, labels1 + labels2, loc="best", framealpha=0.8)
115
+ plt.tight_layout()
116
+
117
+
118
+ class KeiroDashboard:
119
+
120
+ def __init__(self, figsize=(18, 12), dpi=150, theme="dark"):
121
+ _check_matplotlib()
122
+ self.dpi = dpi
123
+ self.theme = theme
124
+ self.fig, self.axes = plt.subplots(2, 2, figsize=figsize)
125
+ self._apply_theme()
126
+
127
+ def _apply_theme(self):
128
+ bg = KeiroPalette.BG_DARK if self.theme == "dark" else KeiroPalette.BG_LIGHT
129
+ grid = KeiroPalette.GRID_DARK if self.theme == "dark" else KeiroPalette.GRID_LIGHT
130
+ fg = 'white' if self.theme == "dark" else 'black'
131
+ self.fig.patch.set_facecolor(bg)
132
+
133
+ for ax in self.axes.flat:
134
+ ax.set_facecolor(bg)
135
+ ax.tick_params(colors=fg)
136
+ ax.xaxis.label.set_color(fg)
137
+ ax.yaxis.label.set_color(fg)
138
+ ax.title.set_color(fg)
139
+ ax.grid(True, alpha=0.2, color=grid)
140
+ for spine in ax.spines.values():
141
+ spine.set_color(grid)
142
+
143
+ def plot_memory_scaling(self, ax_idx=(0,0), seq_lens=None, data_dict=None):
144
+ ax = self.axes[ax_idx]
145
+ ax.set_title("Peak Memory vs Sequence Length", fontsize=14, fontweight='bold')
146
+ ax.set_xlabel("Sequence Length")
147
+ ax.set_ylabel("Memory (MB)")
148
+ if seq_lens and data_dict:
149
+ for k, v in data_dict.items():
150
+ color = KeiroPalette.PRIMARY["dense"] if "Dense" in k else KeiroPalette.PRIMARY["moe"]
151
+ ax.plot(seq_lens, v, label=k, color=color, marker='o', linewidth=2)
152
+ ax.legend()
153
+
154
+ def plot_throughput(self, ax_idx=(0,1), seq_lens=None, data_dict=None):
155
+ ax = self.axes[ax_idx]
156
+ ax.set_title("Inference Throughput (tokens/sec)", fontsize=14, fontweight='bold')
157
+ ax.set_xlabel("Sequence Length")
158
+ ax.set_ylabel("Throughput")
159
+ if seq_lens and data_dict:
160
+ for k, v in data_dict.items():
161
+ color = KeiroPalette.PRIMARY["dense"] if "Dense" in k else KeiroPalette.PRIMARY["moe"]
162
+ ax.plot(seq_lens, v, label=k, color=color, marker='s', linewidth=2)
163
+ ax.legend()
164
+
165
+ def plot_expert_load(self, ax_idx=(1,0), expert_distribution=None):
166
+ ax = self.axes[ax_idx]
167
+ ax.set_title("MoE Expert Load Balancing", fontsize=14, fontweight='bold')
168
+ ax.set_xlabel("Expert ID")
169
+ ax.set_ylabel("Tokens Assigned (%)")
170
+ if expert_distribution:
171
+ x = np.arange(len(expert_distribution))
172
+ colors = [KeiroPalette.EXPERTS[i % len(KeiroPalette.EXPERTS)] for i in x]
173
+ total = sum(expert_distribution)
174
+ pcts = [100.0 * c / total for c in expert_distribution] if total > 0 else expert_distribution
175
+ ax.bar(x, pcts, color=colors, alpha=0.8)
176
+ ax.set_xticks(x)
177
+ ax.set_xticklabels([f"E{i}" for i in x])
178
+ ax.axhline(100.0 / len(expert_distribution), color='gray', linestyle='--', label='Perfect Balance')
179
+ ax.legend()
180
+
181
+ def plot_speedup(self, ax_idx=(1,1), seq_lens=None, base_time=None, moe_time=None):
182
+ ax = self.axes[ax_idx]
183
+ ax.set_title("MoE Speedup vs Dense", fontsize=14, fontweight='bold')
184
+ ax.set_xlabel("Sequence Length")
185
+ ax.set_ylabel("Speedup (x)")
186
+ ax.axhline(1.0, color='gray', linestyle='--', alpha=0.5)
187
+ if seq_lens and base_time and moe_time:
188
+ speedups = [b/m if m > 0 else 0 for b, m in zip(base_time, moe_time)]
189
+ ax.plot(seq_lens, speedups, color=KeiroPalette.PRIMARY["expert_avg"], marker='D', linewidth=2, label="Speedup")
190
+ ax.legend()
191
+
192
+ def save(self, filepath: Union[str, Path]):
193
+ filepath = Path(filepath)
194
+ filepath.parent.mkdir(parents=True, exist_ok=True)
195
+ plt.tight_layout()
196
+ self.fig.savefig(filepath, dpi=self.dpi, bbox_inches="tight", facecolor=self.fig.get_facecolor())
197
+
198
+ def close(self):
199
+ plt.close(self.fig)
200
+
201
+ class ColorPalette(KeiroPalette):
202
+ pass
203
+
204
+ class DomainScorePlot(BasePlot):
205
+ def __init__(self, figsize=(10, 6), **kwargs):
206
+ super().__init__(figsize=figsize, title="Per-Domain Perplexity", **kwargs)
207
+
208
+ def plot_comparison(self, rows: List[Dict], include_dense: bool = False):
209
+ if not rows: return
210
+ domains = [r["domain"] for r in rows]
211
+ before = [r["ppl_before"] for r in rows]
212
+ after = [r["ppl_after"] for r in rows]
213
+ x = np.arange(len(domains))
214
+ width = 0.35 if not include_dense else 0.25
215
+ self.ax.bar(x - width/2, before, width, label='Before (Dense)', color=KeiroPalette.PRIMARY["dense"])
216
+ self.ax.bar(x + width/2, after, width, label='After (MoE)', color=KeiroPalette.PRIMARY["moe"])
217
+ if include_dense:
218
+ dense = [r.get("ppl_dense", 0) for r in rows]
219
+ self.ax.bar(x + 1.5*width, dense, width, label='Dense Baseline', color=KeiroPalette.PRIMARY["expert_avg"])
220
+ self.ax.set_xticks(x)
221
+ self.ax.set_xticklabels(domains, rotation=45, ha='right')
222
+ self.ax.set_ylabel("Perplexity (Lower is better)")
223
+ self.ax.legend()
224
+ self.fig.tight_layout()
225
+
226
+ class TrainingConvergencePlot(BasePlot):
227
+ def __init__(self, figsize=(10, 6), **kwargs):
228
+ super().__init__(figsize=figsize, title="Training Convergence", **kwargs)
229
+
230
+ def plot_history(self, history: Dict):
231
+ train_loss = history.get("train_loss", [])
232
+ val_loss = history.get("val_loss", [])
233
+ if train_loss:
234
+ self.ax.plot(train_loss, label="Train Loss", color=KeiroPalette.PRIMARY["dense"])
235
+ if val_loss:
236
+ if len(val_loss) < len(train_loss):
237
+ x_val = np.linspace(0, len(train_loss)-1, len(val_loss))
238
+ self.ax.plot(x_val, val_loss, label="Val Loss", marker='o', color=KeiroPalette.PRIMARY["moe"])
239
+ else:
240
+ self.ax.plot(val_loss, label="Val Loss", color=KeiroPalette.PRIMARY["moe"])
241
+ self.ax.set_xlabel("Steps (or Epochs)")
242
+ self.ax.set_ylabel("Cross Entropy Loss")
243
+ self.ax.legend()
244
+
245
+ class ExpertRoutingHeatmap(BasePlot):
246
+ def __init__(self, figsize=(12, 8), **kwargs):
247
+ super().__init__(figsize=figsize, title="Expert Routing by Domain", **kwargs)
248
+
249
+ def plot_routing(self, spec_dict: Dict):
250
+ affinity = spec_dict.get("affinity")
251
+ domains = spec_dict.get("domains")
252
+ labels = spec_dict.get("expert_labels")
253
+
254
+ if affinity is None or domains is None:
255
+ return
256
+
257
+ # Convert torch tensor to numpy
258
+ if hasattr(affinity, "cpu"):
259
+ matrix = affinity.cpu().numpy()
260
+ else:
261
+ matrix = np.array(affinity)
262
+
263
+ # Plotting
264
+ im = self.ax.imshow(matrix, aspect="auto", cmap="viridis")
265
+ self.ax.set_xticks(range(len(domains)))
266
+ self.ax.set_xticklabels(domains, rotation=45, ha='right')
267
+
268
+ # Only show individual expert labels if there aren't too many
269
+ if labels and len(labels) <= 64:
270
+ self.ax.set_yticks(range(len(labels)))
271
+ self.ax.set_yticklabels(labels, fontsize=6)
272
+ else:
273
+ self.ax.set_ylabel(f"{len(labels)} Layer-Experts")
274
+ self.ax.set_yticks([]) # Hide Y-axis labels for readability if too dense
275
+
276
+ self.fig.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04)
277
+ self.fig.tight_layout()
278
+