add: sparse_moe architecture source
Browse files- architecture/sparse_moe/__init__.py +113 -0
- architecture/sparse_moe/analysis.py +151 -0
- architecture/sparse_moe/baselines.py +290 -0
- architecture/sparse_moe/config.py +332 -0
- architecture/sparse_moe/datasets.py +228 -0
- architecture/sparse_moe/evaluation.py +241 -0
- architecture/sparse_moe/experts.py +118 -0
- architecture/sparse_moe/injection.py +129 -0
- architecture/sparse_moe/layers.py +338 -0
- architecture/sparse_moe/prompts.py +72 -0
- architecture/sparse_moe/reporting.py +324 -0
- architecture/sparse_moe/routing.py +171 -0
- architecture/sparse_moe/stage_runner.py +65 -0
- architecture/sparse_moe/sys_profiler.py +157 -0
- architecture/sparse_moe/trainer.py +362 -0
- architecture/sparse_moe/utils.py +51 -0
- architecture/sparse_moe/visualization.py +278 -0
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("")
|
| 210 |
+
lines.append("")
|
| 211 |
+
lines.append("")
|
| 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 |
+
|