Project-Chronos 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. chronos/__init__.py +16 -0
  2. chronos/_backend_legacy.py +64 -0
  3. chronos/app.py +122 -0
  4. chronos/backend/__init__.py +40 -0
  5. chronos/backend/dispatcher.py +347 -0
  6. chronos/backend/ext/__init__.py +2 -0
  7. chronos/backend/ext/opencl.py +22 -0
  8. chronos/cli.py +62 -0
  9. chronos/data/__init__.py +0 -0
  10. chronos/data/flexible_dataset.py +309 -0
  11. chronos/deps.py +72 -0
  12. chronos/eval/__init__.py +2 -0
  13. chronos/eval/benchmark.py +159 -0
  14. chronos/eval/io_profiler.py +232 -0
  15. chronos/io/__init__.py +13 -0
  16. chronos/io/async_prefetcher.py +220 -0
  17. chronos/io/cluster_layout.py +317 -0
  18. chronos/io/expert_store.py +559 -0
  19. chronos/io/io_simulator.py +40 -0
  20. chronos/io/storage.py +237 -0
  21. chronos/mlx/__init__.py +32 -0
  22. chronos/mlx/attention.py +230 -0
  23. chronos/mlx/expert_store.py +155 -0
  24. chronos/mlx/inference.py +159 -0
  25. chronos/mlx/model.py +181 -0
  26. chronos/mlx/moe.py +102 -0
  27. chronos/model/__init__.py +6 -0
  28. chronos/model/config.py +54 -0
  29. chronos/model/hf_io.py +149 -0
  30. chronos/model/hybrid_attention.py +237 -0
  31. chronos/model/lookahead_router.py +55 -0
  32. chronos/model/model_chronos.py +271 -0
  33. chronos/model/moe_chronos.py +189 -0
  34. chronos/model/temporal_loss.py +124 -0
  35. chronos/router/__init__.py +12 -0
  36. chronos/router/expert_predictor.py +163 -0
  37. chronos/router/intent_classifier.py +193 -0
  38. chronos/router/prefill_scheduler.py +248 -0
  39. chronos/runtime/__init__.py +2 -0
  40. chronos/runtime/cache_manager.py +117 -0
  41. chronos/runtime/inference_engine.py +295 -0
  42. chronos/runtime/metrics.py +75 -0
  43. chronos/serving/__init__.py +13 -0
  44. chronos/serving/vllm_adapter.py +85 -0
  45. chronos/trainer/__init__.py +6 -0
  46. chronos/trainer/chronos_trainer.py +151 -0
  47. chronos/trainer/distill_trainer.py +193 -0
  48. chronos/trainer/dpo_trainer.py +190 -0
  49. chronos/trainer/grpo_trainer.py +288 -0
  50. chronos/trainer/loss_mixin.py +165 -0
  51. chronos/trainer/optim_utils.py +101 -0
  52. chronos/trainer/orpo_trainer.py +189 -0
  53. chronos/trainer/reward.py +130 -0
  54. chronos/trainer/sft_trainer.py +189 -0
  55. chronos/tuning/__init__.py +1 -0
  56. chronos/tuning/_base_tuner.py +284 -0
  57. chronos/tuning/chronos_auto_tuner.py +203 -0
  58. project_chronos-0.1.1.dist-info/METADATA +670 -0
  59. project_chronos-0.1.1.dist-info/RECORD +83 -0
  60. project_chronos-0.1.1.dist-info/WHEEL +5 -0
  61. project_chronos-0.1.1.dist-info/entry_points.txt +7 -0
  62. project_chronos-0.1.1.dist-info/licenses/LICENSE +177 -0
  63. project_chronos-0.1.1.dist-info/top_level.txt +8 -0
  64. train_chronos.py +133 -0
  65. train_chronos_distill.py +95 -0
  66. train_chronos_dpo.py +82 -0
  67. train_chronos_grpo.py +88 -0
  68. train_chronos_orpo.py +81 -0
  69. train_chronos_sft.py +85 -0
  70. ui/__init__.py +3 -0
  71. ui/estimator.py +204 -0
  72. ui/gradio_compat.py +72 -0
  73. ui/i18n.py +540 -0
  74. ui/presets.py +194 -0
  75. ui/tabs/__init__.py +3 -0
  76. ui/tabs/autotune_tab.py +210 -0
  77. ui/tabs/benchmark_tab.py +196 -0
  78. ui/tabs/config_tab.py +318 -0
  79. ui/tabs/data_tab.py +148 -0
  80. ui/tabs/inference_tab.py +496 -0
  81. ui/tabs/iomon_tab.py +37 -0
  82. ui/tabs/pipeline_tab.py +134 -0
  83. ui/tabs/train_tab.py +1688 -0
@@ -0,0 +1,189 @@
1
+ import sys
2
+ import chronos.deps # ensure minimind on sys.path
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+ import torch.nn.functional as F
7
+ from transformers.activations import ACT2FN
8
+
9
+ from model.model_minimind import FeedForward, MOEFeedForward
10
+ from .config import ChronosConfig
11
+
12
+
13
+ class LazyExpertPlaceholder(nn.Module):
14
+ """Inference-only placeholder for experts that live on SSD/RAM.
15
+
16
+ If this module executes directly, Chronos failed to materialize the
17
+ expert before use or failed to route the token through the shared
18
+ expert fallback. That is a correctness bug, so we raise loudly.
19
+ """
20
+
21
+ def __init__(self, hidden_size: int, intermediate_size: int, dtype: torch.dtype):
22
+ super().__init__()
23
+ self.hidden_size = hidden_size
24
+ self.intermediate_size = intermediate_size
25
+ self.dtype = dtype
26
+ self.loaded = False
27
+
28
+ def forward(self, _x):
29
+ raise RuntimeError("lazy expert placeholder executed before materialization")
30
+
31
+
32
+ class ChronosMOEFeedForward(nn.Module):
33
+ """
34
+ Extends MOEFeedForward with:
35
+ 1. shared_experts: always resident in VRAM, used as soft fallback
36
+ 2. soft_gate: when target experts are unavailable (cache miss), blend
37
+ their routing weight into shared experts instead of blocking on I/O
38
+
39
+ Training (available_expert_mask=None): identical to MOEFeedForward,
40
+ plus stores per-token routing probs for temporal_locality_loss.
41
+
42
+ Inference (available_expert_mask provided): routes cache-miss tokens
43
+ to shared experts with zero latency.
44
+ """
45
+
46
+ def __init__(self, config: ChronosConfig):
47
+ super().__init__()
48
+ self.config = config
49
+ self.num_experts = config.num_experts
50
+ self.num_experts_per_tok = config.num_experts_per_tok
51
+ self.norm_topk_prob = config.norm_topk_prob
52
+ self.router_aux_loss_coef = config.router_aux_loss_coef
53
+
54
+ self.gate = nn.Linear(config.hidden_size, config.num_experts, bias=False)
55
+ self.experts = nn.ModuleList([
56
+ FeedForward(config, intermediate_size=config.moe_intermediate_size)
57
+ for _ in range(config.num_experts)
58
+ ])
59
+ self.shared_experts = nn.ModuleList([
60
+ FeedForward(config, intermediate_size=config.moe_intermediate_size)
61
+ for _ in range(config.num_shared_experts)
62
+ ])
63
+ self.aux_loss = torch.zeros(1)
64
+ # raw, *unscaled* load-balance value computed in forward(). The
65
+ # trainer-side λ_balance lives in chronos_loss_term so we don't
66
+ # double-scale (router_aux_loss_coef × λ_balance gave a 2 000×
67
+ # under-weighting and let the gate degenerate). Set to None until
68
+ # the first training forward populates it.
69
+ self.aux_loss_raw: torch.Tensor = None
70
+ # Stores routing probs for downstream loss / metrics.
71
+ # We keep TWO refs:
72
+ # last_router_probs — detached, safe for logging / cluster
73
+ # layout / inference engine prefetch
74
+ # (consumers that must NOT change grad).
75
+ # last_router_probs_grad — live tensor with autograd attached,
76
+ # consumed by chronos_loss_term so the
77
+ # λ_temporal / λ_lookahead penalties
78
+ # actually shape the gate.
79
+ self.last_router_probs: torch.Tensor = None
80
+ self.last_router_probs_grad: torch.Tensor = None
81
+
82
+ def _shared_expert_output(self, x_flat: torch.Tensor) -> torch.Tensor:
83
+ """Average output of all shared experts."""
84
+ out = sum(e(x_flat) for e in self.shared_experts)
85
+ return out / len(self.shared_experts)
86
+
87
+ def forward(
88
+ self,
89
+ x: torch.Tensor,
90
+ available_expert_mask: torch.Tensor = None,
91
+ ) -> torch.Tensor:
92
+ """
93
+ Args:
94
+ x: [B, S, H]
95
+ available_expert_mask: [num_experts] bool or float — which experts
96
+ are in VRAM. None during training (all experts available).
97
+
98
+ When available_expert_mask is provided the forward pass is written
99
+ as pure tensor arithmetic (no Python if/else per expert) so that
100
+ torch.compile can trace a single static graph without graph breaks.
101
+ """
102
+ B, S, H = x.shape
103
+ x_flat = x.view(-1, H) # [N, H]
104
+ scores = F.softmax(self.gate(x_flat), dim=-1) # [N, E]
105
+ scores_bse = scores.view(B, S, self.num_experts)
106
+ # Live (with-grad) view drives the temporal / lookahead losses.
107
+ self.last_router_probs_grad = scores_bse
108
+ # Detached view for read-only consumers (logging, cluster layout,
109
+ # inference-engine prefetch). Same tensor data, no autograd edge.
110
+ self.last_router_probs = scores_bse.detach()
111
+
112
+ topk_weight, topk_idx = torch.topk(
113
+ scores, k=self.num_experts_per_tok, dim=-1, sorted=False
114
+ )
115
+ if self.norm_topk_prob:
116
+ topk_weight = topk_weight / (topk_weight.sum(dim=-1, keepdim=True) + 1e-20)
117
+
118
+ y = torch.zeros_like(x_flat)
119
+
120
+ if available_expert_mask is None:
121
+ # --- Training path: standard MoE dispatch + always-on shared expert ---
122
+ dead_param_sum = None
123
+ for i, expert in enumerate(self.experts):
124
+ mask = (topk_idx == i)
125
+ if mask.any():
126
+ token_idx = mask.any(dim=-1).nonzero().flatten()
127
+ weight = topk_weight[mask].view(-1, 1)
128
+ y.index_add_(0, token_idx, (expert(x_flat[token_idx]) * weight).to(y.dtype))
129
+ elif self.training:
130
+ # Dead-expert handling: keep the autograd edge alive
131
+ # WITHOUT touching y[0,0] (the previous trick poisoned
132
+ # the BOS position every step). We accumulate a
133
+ # 0·sum(params) scalar that gets routed into aux_loss
134
+ # below, which the trainer always backprops on.
135
+ contrib = sum(p.sum() for p in expert.parameters())
136
+ dead_param_sum = (
137
+ contrib if dead_param_sum is None else dead_param_sum + contrib
138
+ )
139
+
140
+ # Always-on shared expert (mirrors the inference branch). The
141
+ # previous code only ran shared_experts under the inference
142
+ # mask path, so during training they received zero gradient
143
+ # — and at inference, the cache-miss fallback fired untrained
144
+ # weights, which is the dominant root cause of "loss looks
145
+ # fine but generations are gibberish."
146
+ if len(self.shared_experts) > 0:
147
+ y = y + self._shared_expert_output(x_flat).to(y.dtype)
148
+
149
+ if self.training:
150
+ # Raw, un-scaled load balance (model exposes raw; trainer scales).
151
+ load = F.one_hot(topk_idx, self.num_experts).float().mean(0)
152
+ aux_raw = (load * scores.mean(0)).sum() * self.num_experts
153
+ if dead_param_sum is not None:
154
+ aux_raw = aux_raw + 0.0 * dead_param_sum
155
+ self.aux_loss_raw = aux_raw
156
+ # Backwards-compat: the legacy `aux_loss` field stays
157
+ # populated with the pre-scaled value some callers still
158
+ # read (chronos_loss_term now prefers aux_loss_raw).
159
+ self.aux_loss = aux_raw * self.router_aux_loss_coef
160
+ else:
161
+ self.aux_loss_raw = scores.new_zeros(())
162
+ self.aux_loss = scores.new_zeros(())
163
+
164
+ else:
165
+ # --- Inference path: real lazy experts + shared fallback ---
166
+ # We intentionally branch at the Python level here: when an
167
+ # expert is cold we must not execute it at all, because the
168
+ # live module may have been replaced by a placeholder after
169
+ # expert offload. A multiply-by-zero path would still call the
170
+ # unloaded expert and defeat lazy loading entirely.
171
+ avail = available_expert_mask.to(dtype=x_flat.dtype, device=x_flat.device) # [E]
172
+ shared_out_full = self._shared_expert_output(x_flat) # [N, H] — computed once
173
+
174
+ for i, expert in enumerate(self.experts):
175
+ mask = (topk_idx == i) # [N, K] bool
176
+ if not mask.any():
177
+ continue
178
+ token_idx = mask.any(dim=-1).nonzero().flatten() # [T]
179
+ weight = topk_weight[mask].view(-1, 1) # [T, 1]
180
+ fallback_out = shared_out_full[token_idx] # [T, H]
181
+ if float(avail[i].item()) > 0.0:
182
+ blended = expert(x_flat[token_idx]) # [T, H]
183
+ else:
184
+ blended = fallback_out
185
+ y.index_add_(0, token_idx, (blended * weight).to(y.dtype))
186
+
187
+ self.aux_loss = scores.new_zeros(1).squeeze()
188
+
189
+ return y.view(B, S, H)
@@ -0,0 +1,124 @@
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+
5
+ def temporal_locality_loss(expert_probs_seq: torch.Tensor) -> torch.Tensor:
6
+ """
7
+ Penalizes large routing distribution shifts between adjacent time steps.
8
+
9
+ L_temporal = (1/T) * sum_{t=2}^{T} ||E_t - E_{t-1}||_2^2
10
+
11
+ Args:
12
+ expert_probs_seq: [B, T, num_experts] per-step routing probabilities
13
+ Returns:
14
+ scalar loss
15
+ """
16
+ diff = expert_probs_seq[:, 1:, :] - expert_probs_seq[:, :-1, :]
17
+ return (diff ** 2).sum(dim=-1).mean()
18
+
19
+
20
+ def load_balance_loss(
21
+ router_logits: torch.Tensor,
22
+ num_experts: int,
23
+ num_experts_per_tok: int,
24
+ ) -> torch.Tensor:
25
+ """
26
+ Standard auxiliary load-balancing loss (same formulation as minimind).
27
+
28
+ Args:
29
+ router_logits: [N, num_experts] (N = B*S flattened)
30
+ Returns:
31
+ scalar loss
32
+ """
33
+ scores = F.softmax(router_logits, dim=-1)
34
+ _, topk_idx = torch.topk(scores, k=num_experts_per_tok, dim=-1, sorted=False)
35
+ load = F.one_hot(topk_idx, num_experts).float().mean(0)
36
+ return (load * scores.mean(0)).sum() * num_experts
37
+
38
+
39
+ def lookahead_supervision_loss(
40
+ lookahead_probs: torch.Tensor,
41
+ teacher_router_probs: torch.Tensor,
42
+ lookahead_steps: int,
43
+ ) -> torch.Tensor:
44
+ """
45
+ Teacher-forcing loss for the LookaheadRouter.
46
+
47
+ At time t the router predicts the routing distribution at t+k for
48
+ k ∈ {1..lookahead_steps}. We supervise those predictions with the
49
+ *actual* layer-averaged router probabilities from step t+k (stop-grad).
50
+
51
+ Formulation (soft-target cross-entropy, equivalent to forward KL
52
+ up to a teacher entropy constant):
53
+
54
+ L = (1 / K) · Σ_{k=1..K} [ - mean_{t} Σ_e P_{t+k}[e] · log Q_t^(k)[e] ]
55
+
56
+ Args:
57
+ lookahead_probs: [B, S, K+1, E] — softmax'd output of
58
+ LookaheadRouter. Index 0 = current step,
59
+ indices 1..K = future-step predictions.
60
+ teacher_router_probs: [B, S, E] — layer-averaged real router
61
+ probabilities. Caller MUST pass a detached
62
+ (stop-grad) tensor.
63
+ lookahead_steps: K, how many future steps to supervise.
64
+
65
+ Returns:
66
+ scalar loss. Zero when the sequence is too short to form a pair.
67
+ """
68
+ if lookahead_probs is None or teacher_router_probs is None:
69
+ return lookahead_probs.new_zeros(()) if lookahead_probs is not None else \
70
+ teacher_router_probs.new_zeros(())
71
+
72
+ B, S, Kp1, E = lookahead_probs.shape
73
+ K = min(lookahead_steps, Kp1 - 1)
74
+ if K <= 0 or S <= 1:
75
+ return lookahead_probs.new_zeros(())
76
+
77
+ teacher = teacher_router_probs # caller is responsible for .detach()
78
+ total = lookahead_probs.new_zeros(())
79
+ valid_terms = 0
80
+ for k in range(1, K + 1):
81
+ if S - k <= 0:
82
+ continue
83
+ teacher_k = teacher[:, k:, :] # [B, S-k, E]
84
+ pred_k = lookahead_probs[:, :-k, k, :] # [B, S-k, E]
85
+ # Soft-target CE: −Σ_e P · log Q
86
+ log_pred = pred_k.clamp_min(1e-9).log()
87
+ ce = -(teacher_k * log_pred).sum(dim=-1) # [B, S-k]
88
+ total = total + ce.mean()
89
+ valid_terms += 1
90
+ if valid_terms == 0:
91
+ return lookahead_probs.new_zeros(())
92
+ return total / valid_terms
93
+
94
+
95
+ def total_loss(
96
+ ce_loss: torch.Tensor,
97
+ balance_loss: torch.Tensor,
98
+ expert_probs_seq: torch.Tensor,
99
+ lambda1: float,
100
+ lambda2: float,
101
+ *,
102
+ lookahead_probs: torch.Tensor = None,
103
+ teacher_probs: torch.Tensor = None,
104
+ lookahead_steps: int = 0,
105
+ lambda_lookahead: float = 0.0,
106
+ ) -> torch.Tensor:
107
+ """
108
+ L_total = L_CE + λ1·L_balance + λ2·L_temporal + λ_lookahead·L_lookahead
109
+
110
+ The lookahead term is only applied when `lookahead_probs`, `teacher_probs`
111
+ and `lambda_lookahead > 0` are all provided — preserves the M1-era
112
+ signature for legacy callers.
113
+ """
114
+ loss = ce_loss + lambda1 * balance_loss + lambda2 * temporal_locality_loss(expert_probs_seq)
115
+ if (
116
+ lookahead_probs is not None
117
+ and teacher_probs is not None
118
+ and lambda_lookahead > 0.0
119
+ and lookahead_steps > 0
120
+ ):
121
+ loss = loss + lambda_lookahead * lookahead_supervision_loss(
122
+ lookahead_probs, teacher_probs, lookahead_steps,
123
+ )
124
+ return loss
@@ -0,0 +1,12 @@
1
+ """
2
+ chronos/router/__init__.py
3
+ """
4
+ from chronos.router.intent_classifier import IntentClassifier, IntentVector
5
+ from chronos.router.expert_predictor import ExpertPredictor, ExpertSet
6
+ from chronos.router.prefill_scheduler import PrefillScheduler
7
+
8
+ __all__ = [
9
+ "IntentClassifier", "IntentVector",
10
+ "ExpertPredictor", "ExpertSet",
11
+ "PrefillScheduler",
12
+ ]
@@ -0,0 +1,163 @@
1
+ """
2
+ chronos/router/expert_predictor.py
3
+
4
+ Layer 2 of the routing pipeline: converts IntentVector probabilities into
5
+ a concrete expert set to pre-load before generation begins.
6
+
7
+ Responsibilities:
8
+ 1. Threshold-based selection: keep experts with prob > threshold
9
+ 2. Budget enforcement: cap at vram_capacity experts
10
+ 3. Per-layer masks for soft gating (avail_masks)
11
+ 4. Confidence reporting: if max_prob < min_confidence, fall back to
12
+ top-K heuristic (always load the K most common experts)
13
+
14
+ The ExpertPredictor is stateless — it takes an IntentVector and returns
15
+ an ExpertSet. No weights, no training needed.
16
+ """
17
+ from dataclasses import dataclass, field
18
+ from typing import List, Optional, Set, TYPE_CHECKING
19
+
20
+ import torch
21
+
22
+ if TYPE_CHECKING:
23
+ from chronos.router.intent_classifier import IntentVector
24
+
25
+
26
+ @dataclass
27
+ class ExpertSet:
28
+ """
29
+ The concrete set of experts to pre-load for a generation session.
30
+
31
+ expert_ids: flat set of expert IDs to load (union across all layers)
32
+ per_layer_masks: List[Tensor([num_experts], bool)] — per-layer avail
33
+ confidence: float — how confident the classifier was (0–1)
34
+ fallback: bool — True if we fell back to heuristic selection
35
+ """
36
+ expert_ids: Set[int]
37
+ per_layer_masks: List[torch.Tensor]
38
+ confidence: float
39
+ fallback: bool = False
40
+
41
+ def __repr__(self):
42
+ return (f"ExpertSet(experts={sorted(self.expert_ids)}, "
43
+ f"confidence={self.confidence:.2f}, fallback={self.fallback})")
44
+
45
+
46
+ class ExpertPredictor:
47
+ """
48
+ Converts IntentVector → ExpertSet.
49
+
50
+ Args:
51
+ num_experts: total number of experts
52
+ num_moe_layers: number of MoE layers
53
+ vram_capacity: max experts that fit in VRAM simultaneously
54
+ threshold: minimum probability to include an expert
55
+ min_confidence: if best prob < this, use fallback heuristic
56
+ fallback_top_k: number of experts to always include as fallback
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ num_experts: int,
62
+ num_moe_layers: int,
63
+ vram_capacity: int = 4,
64
+ threshold: float = 0.25,
65
+ min_confidence: float = 0.15,
66
+ fallback_top_k: int = 2,
67
+ ):
68
+ self.num_experts = num_experts
69
+ self.num_moe_layers = num_moe_layers
70
+ self.vram_capacity = vram_capacity
71
+ self.threshold = threshold
72
+ self.min_confidence = min_confidence
73
+ self.fallback_top_k = fallback_top_k
74
+
75
+ # Running frequency table: updated from actual activations over time
76
+ self._frequency: torch.Tensor = torch.ones(num_experts) / num_experts
77
+
78
+ def predict(self, intent: "IntentVector") -> ExpertSet:
79
+ """
80
+ Convert IntentVector to a concrete ExpertSet.
81
+
82
+ Strategy:
83
+ 1. Check confidence (max prob across all layers)
84
+ 2. If confident: threshold + budget cap
85
+ 3. If not confident: fallback to global frequency heuristic
86
+ """
87
+ from chronos.router.intent_classifier import IntentVector
88
+
89
+ global_p = intent.global_probs # [E]
90
+ confidence = float(global_p.max().item())
91
+ fallback = confidence < self.min_confidence
92
+
93
+ if fallback:
94
+ # Heuristic: always load the historically most-activated experts
95
+ top_ids = self._frequency.topk(
96
+ min(self.fallback_top_k, self.vram_capacity)
97
+ ).indices.tolist()
98
+ expert_ids = set(top_ids)
99
+ else:
100
+ # Per-layer selection
101
+ selected_per_layer = []
102
+ all_ids: Set[int] = set()
103
+ for layer_p in intent.per_layer_probs:
104
+ above = (layer_p >= self.threshold).nonzero().flatten().tolist()
105
+ selected_per_layer.append(set(above))
106
+ all_ids.update(above)
107
+
108
+ # Enforce budget: keep highest-prob experts if over capacity
109
+ if len(all_ids) > self.vram_capacity:
110
+ top_ids = global_p.topk(self.vram_capacity).indices.tolist()
111
+ all_ids = set(top_ids)
112
+
113
+ expert_ids = all_ids
114
+
115
+ # Build per-layer boolean masks
116
+ per_layer_masks = []
117
+ for li in range(self.num_moe_layers):
118
+ mask = torch.zeros(self.num_experts, dtype=torch.bool)
119
+ layer_p = intent.per_layer_probs[li] if li < len(intent.per_layer_probs) else global_p
120
+ if fallback:
121
+ for eid in expert_ids:
122
+ mask[eid] = True
123
+ else:
124
+ for eid in expert_ids:
125
+ if layer_p[eid] >= self.threshold or eid in expert_ids:
126
+ mask[eid] = True
127
+ per_layer_masks.append(mask)
128
+
129
+ return ExpertSet(
130
+ expert_ids=expert_ids,
131
+ per_layer_masks=per_layer_masks,
132
+ confidence=confidence,
133
+ fallback=fallback,
134
+ )
135
+
136
+ def update_frequency(self, actual_expert_ids: List[int]):
137
+ """
138
+ Update running activation frequency from observed generation.
139
+ Called after each generation session with the experts that were
140
+ actually activated.
141
+ """
142
+ alpha = 0.05 # EMA smoothing
143
+ observed = torch.zeros(self.num_experts)
144
+ for eid in actual_expert_ids:
145
+ if 0 <= eid < self.num_experts:
146
+ observed[eid] += 1
147
+ if observed.sum() > 0:
148
+ observed = observed / observed.sum()
149
+ self._frequency = (1 - alpha) * self._frequency + alpha * observed
150
+
151
+ def avail_masks_float(self, expert_set: ExpertSet, device=None) -> List[torch.Tensor]:
152
+ """
153
+ Convert per_layer_masks (bool) to float tensors for soft gating.
154
+ 0.0 = cache miss → shared expert fallback
155
+ 1.0 = in VRAM → use directly
156
+ """
157
+ result = []
158
+ for mask in expert_set.per_layer_masks:
159
+ f = mask.float()
160
+ if device is not None:
161
+ f = f.to(device)
162
+ result.append(f)
163
+ return result
@@ -0,0 +1,193 @@
1
+ """
2
+ chronos/router/intent_classifier.py
3
+
4
+ Layer 1 of the dual-layer architecture: a small dense model that reads
5
+ the full prompt during prefill and outputs an "intent vector" —
6
+ a distribution over expert-activation patterns.
7
+
8
+ Design:
9
+ - 2-layer Transformer encoder (~8-15M params, much smaller than main MoE)
10
+ - Input: tokenised prompt (first min(512, seq_len) tokens)
11
+ - Output: IntentVector — a [num_experts] float tensor (per layer) that
12
+ predicts which experts the main MoE will need during generation
13
+
14
+ Why separate from LookaheadRouter:
15
+ LookaheadRouter predicts 1-2 steps ahead within the generation loop.
16
+ IntentClassifier reads the ENTIRE prompt ONCE before generation starts,
17
+ producing a global expert-set prediction that covers the whole reply.
18
+ This moves all IO uncertainty to the prefill phase.
19
+
20
+ Training:
21
+ Supervised on (prompt, expert_activation_log) pairs collected from
22
+ the main model's forward passes. Loss = MSE between predicted and
23
+ actual per-layer expert activation frequencies.
24
+ """
25
+ import os
26
+ from dataclasses import dataclass
27
+ from typing import List, Optional
28
+
29
+ import torch
30
+ import torch.nn as nn
31
+ import torch.nn.functional as F
32
+
33
+
34
+ @dataclass
35
+ class IntentVector:
36
+ """
37
+ Output of IntentClassifier for a single prompt.
38
+
39
+ per_layer_probs: List[Tensor([num_experts])] — one per MoE layer.
40
+ Values are probabilities; higher means more likely to be needed.
41
+ global_probs: Tensor([num_experts]) — averaged across layers.
42
+ """
43
+ per_layer_probs: List[torch.Tensor]
44
+ global_probs: torch.Tensor
45
+
46
+
47
+ class IntentClassifier(nn.Module):
48
+ """
49
+ Small dense encoder that maps a prompt to an expert activation prediction.
50
+
51
+ Architecture: embedding → 2 Transformer encoder layers → mean-pool →
52
+ linear head → sigmoid per-layer expert probabilities
53
+
54
+ Parameters: ~10-15M (hidden=256, heads=4, layers=2)
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ vocab_size: int,
60
+ num_experts: int,
61
+ num_moe_layers: int,
62
+ hidden_size: int = 256,
63
+ num_heads: int = 4,
64
+ num_encoder_layers: int = 2,
65
+ max_seq_len: int = 512,
66
+ dropout: float = 0.1,
67
+ ):
68
+ super().__init__()
69
+ self.num_experts = num_experts
70
+ self.num_moe_layers = num_moe_layers
71
+ self.hidden_size = hidden_size
72
+ self.max_seq_len = max_seq_len
73
+
74
+ self.embed = nn.Embedding(vocab_size, hidden_size, padding_idx=0)
75
+ self.pos_embed = nn.Embedding(max_seq_len, hidden_size)
76
+
77
+ encoder_layer = nn.TransformerEncoderLayer(
78
+ d_model=hidden_size,
79
+ nhead=num_heads,
80
+ dim_feedforward=hidden_size * 4,
81
+ dropout=dropout,
82
+ batch_first=True,
83
+ norm_first=True,
84
+ )
85
+ self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)
86
+ self.norm = nn.LayerNorm(hidden_size)
87
+
88
+ # Per-layer expert probability heads
89
+ # Output: [num_moe_layers, num_experts] per sample
90
+ self.head = nn.Linear(hidden_size, num_moe_layers * num_experts)
91
+ self.dropout = nn.Dropout(dropout)
92
+
93
+ def forward(self, input_ids: torch.Tensor) -> IntentVector:
94
+ """
95
+ Args:
96
+ input_ids: [B, S] token ids (truncated to max_seq_len)
97
+ Returns:
98
+ IntentVector with per-layer and global expert probabilities
99
+ """
100
+ B, S = input_ids.shape
101
+ S = min(S, self.max_seq_len)
102
+ ids = input_ids[:, :S]
103
+
104
+ pos = torch.arange(S, device=ids.device).unsqueeze(0) # [1, S]
105
+ x = self.embed(ids) + self.pos_embed(pos) # [B, S, H]
106
+ x = self.dropout(x)
107
+
108
+ # Padding mask for attention (0 = pad token)
109
+ pad_mask = (ids == 0) # [B, S]
110
+
111
+ x = self.encoder(x, src_key_padding_mask=pad_mask)
112
+ x = self.norm(x)
113
+
114
+ # Mean-pool over non-padding tokens
115
+ lengths = (~pad_mask).float().sum(dim=1, keepdim=True).clamp(min=1) # [B, 1]
116
+ x_masked = x * (~pad_mask).float().unsqueeze(-1)
117
+ pooled = x_masked.sum(dim=1) / lengths # [B, H]
118
+
119
+ logits = self.head(pooled) # [B, L*E]
120
+ probs = torch.sigmoid(logits).view(B, self.num_moe_layers, self.num_experts)
121
+
122
+ # Build IntentVector (for batch size 1 during inference)
123
+ per_layer = [probs[0, i] for i in range(self.num_moe_layers)]
124
+ global_p = probs[0].mean(dim=0)
125
+ return IntentVector(per_layer_probs=per_layer, global_probs=global_p)
126
+
127
+ # ── Training helpers ─────────────────────────────────────────
128
+
129
+ @staticmethod
130
+ def collect_activation_targets(
131
+ model,
132
+ input_ids: torch.Tensor,
133
+ device: str,
134
+ ) -> torch.Tensor:
135
+ """
136
+ Run main model forward pass and collect actual expert activation
137
+ frequencies as training labels for IntentClassifier.
138
+
139
+ Returns: [num_moe_layers, num_experts] float tensor (frequencies)
140
+ """
141
+ from chronos.model.moe_chronos import ChronosMOEFeedForward
142
+ model.eval()
143
+ with torch.no_grad():
144
+ model(input_ids.to(device))
145
+
146
+ moe_layers = [l.mlp for l in model.model.layers
147
+ if isinstance(l.mlp, ChronosMOEFeedForward)]
148
+ targets = []
149
+ for moe in moe_layers:
150
+ if moe.last_router_probs is not None:
151
+ # Mean activation probability across tokens
152
+ targets.append(moe.last_router_probs.mean(dim=(0, 1))) # [E]
153
+ else:
154
+ targets.append(torch.zeros(model.config.num_experts))
155
+ return torch.stack(targets) # [L, E]
156
+
157
+ def train_step(
158
+ self,
159
+ input_ids: torch.Tensor,
160
+ targets: torch.Tensor,
161
+ optimizer: torch.optim.Optimizer,
162
+ ) -> float:
163
+ """
164
+ Single training step.
165
+ targets: [B, num_moe_layers, num_experts] float
166
+ """
167
+ self.train()
168
+ optimizer.zero_grad(set_to_none=True)
169
+ B, S = input_ids.shape
170
+ logits = self.head(
171
+ self.norm(self.encoder(
172
+ self.dropout(self.embed(input_ids[:, :self.max_seq_len])
173
+ + self.pos_embed(
174
+ torch.arange(min(S, self.max_seq_len),
175
+ device=input_ids.device).unsqueeze(0)))
176
+ ).mean(dim=1))
177
+ ).view(B, self.num_moe_layers, self.num_experts)
178
+ probs = torch.sigmoid(logits)
179
+ loss = F.mse_loss(probs, targets.to(probs.device))
180
+ loss.backward()
181
+ nn.utils.clip_grad_norm_(self.parameters(), 1.0)
182
+ optimizer.step()
183
+ return loss.item()
184
+
185
+ def save(self, path: str):
186
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
187
+ torch.save(self.state_dict(), path)
188
+
189
+ @classmethod
190
+ def load(cls, path: str, **kwargs) -> "IntentClassifier":
191
+ m = cls(**kwargs)
192
+ m.load_state_dict(torch.load(path, map_location="cpu"))
193
+ return m