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.
- chronos/__init__.py +16 -0
- chronos/_backend_legacy.py +64 -0
- chronos/app.py +122 -0
- chronos/backend/__init__.py +40 -0
- chronos/backend/dispatcher.py +347 -0
- chronos/backend/ext/__init__.py +2 -0
- chronos/backend/ext/opencl.py +22 -0
- chronos/cli.py +62 -0
- chronos/data/__init__.py +0 -0
- chronos/data/flexible_dataset.py +309 -0
- chronos/deps.py +72 -0
- chronos/eval/__init__.py +2 -0
- chronos/eval/benchmark.py +159 -0
- chronos/eval/io_profiler.py +232 -0
- chronos/io/__init__.py +13 -0
- chronos/io/async_prefetcher.py +220 -0
- chronos/io/cluster_layout.py +317 -0
- chronos/io/expert_store.py +559 -0
- chronos/io/io_simulator.py +40 -0
- chronos/io/storage.py +237 -0
- chronos/mlx/__init__.py +32 -0
- chronos/mlx/attention.py +230 -0
- chronos/mlx/expert_store.py +155 -0
- chronos/mlx/inference.py +159 -0
- chronos/mlx/model.py +181 -0
- chronos/mlx/moe.py +102 -0
- chronos/model/__init__.py +6 -0
- chronos/model/config.py +54 -0
- chronos/model/hf_io.py +149 -0
- chronos/model/hybrid_attention.py +237 -0
- chronos/model/lookahead_router.py +55 -0
- chronos/model/model_chronos.py +271 -0
- chronos/model/moe_chronos.py +189 -0
- chronos/model/temporal_loss.py +124 -0
- chronos/router/__init__.py +12 -0
- chronos/router/expert_predictor.py +163 -0
- chronos/router/intent_classifier.py +193 -0
- chronos/router/prefill_scheduler.py +248 -0
- chronos/runtime/__init__.py +2 -0
- chronos/runtime/cache_manager.py +117 -0
- chronos/runtime/inference_engine.py +295 -0
- chronos/runtime/metrics.py +75 -0
- chronos/serving/__init__.py +13 -0
- chronos/serving/vllm_adapter.py +85 -0
- chronos/trainer/__init__.py +6 -0
- chronos/trainer/chronos_trainer.py +151 -0
- chronos/trainer/distill_trainer.py +193 -0
- chronos/trainer/dpo_trainer.py +190 -0
- chronos/trainer/grpo_trainer.py +288 -0
- chronos/trainer/loss_mixin.py +165 -0
- chronos/trainer/optim_utils.py +101 -0
- chronos/trainer/orpo_trainer.py +189 -0
- chronos/trainer/reward.py +130 -0
- chronos/trainer/sft_trainer.py +189 -0
- chronos/tuning/__init__.py +1 -0
- chronos/tuning/_base_tuner.py +284 -0
- chronos/tuning/chronos_auto_tuner.py +203 -0
- project_chronos-0.1.1.dist-info/METADATA +670 -0
- project_chronos-0.1.1.dist-info/RECORD +83 -0
- project_chronos-0.1.1.dist-info/WHEEL +5 -0
- project_chronos-0.1.1.dist-info/entry_points.txt +7 -0
- project_chronos-0.1.1.dist-info/licenses/LICENSE +177 -0
- project_chronos-0.1.1.dist-info/top_level.txt +8 -0
- train_chronos.py +133 -0
- train_chronos_distill.py +95 -0
- train_chronos_dpo.py +82 -0
- train_chronos_grpo.py +88 -0
- train_chronos_orpo.py +81 -0
- train_chronos_sft.py +85 -0
- ui/__init__.py +3 -0
- ui/estimator.py +204 -0
- ui/gradio_compat.py +72 -0
- ui/i18n.py +540 -0
- ui/presets.py +194 -0
- ui/tabs/__init__.py +3 -0
- ui/tabs/autotune_tab.py +210 -0
- ui/tabs/benchmark_tab.py +196 -0
- ui/tabs/config_tab.py +318 -0
- ui/tabs/data_tab.py +148 -0
- ui/tabs/inference_tab.py +496 -0
- ui/tabs/iomon_tab.py +37 -0
- ui/tabs/pipeline_tab.py +134 -0
- 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
|