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
chronos/io/storage.py ADDED
@@ -0,0 +1,237 @@
1
+ """
2
+ chronos/io/storage.py
3
+
4
+ Cluster-aware safetensors backend for Chronos expert weights.
5
+
6
+ A .ctsr file is a plain safetensors container packing every expert in one
7
+ Louvain co-occurrence cluster. Tensor keys follow the pattern
8
+ ``l{layer_id}_e{expert_id}__{param_name}``.
9
+
10
+ A sidecar ``cluster_manifest.json`` maps (cluster_id) → (file, expert_ids)
11
+ and (expert_id) → cluster_id so the runtime can:
12
+
13
+ 1. Look up which .ctsr file owns an expert in O(1).
14
+ 2. Read an entire cluster in a single mmap + zero-copy tensor view, turning
15
+ N random .pt pickle decodes into one sequential pread.
16
+
17
+ The format intentionally stays minimal — no compression, no encryption, no
18
+ tensor sharding — so that it maps cleanly onto future io_uring / DirectIO
19
+ backends without schema churn.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ from dataclasses import dataclass
26
+ from typing import Dict, List, Optional, Tuple
27
+
28
+ import torch
29
+
30
+ try:
31
+ from safetensors.torch import save_file as _st_save_file
32
+ from safetensors import safe_open as _st_safe_open
33
+ _HAS_SAFETENSORS = True
34
+ except ImportError: # pragma: no cover
35
+ _HAS_SAFETENSORS = False
36
+
37
+
38
+ MANIFEST_FILENAME = "cluster_manifest.json"
39
+ MANIFEST_VERSION = 1
40
+ KEY_SEP = "__" # layer/expert prefix vs param-name separator
41
+
42
+
43
+ def _key(layer_id: int, expert_id: int, param_name: str) -> str:
44
+ return f"l{layer_id}_e{expert_id}{KEY_SEP}{param_name}"
45
+
46
+
47
+ def _parse_key(key: str) -> Optional[Tuple[int, int, str]]:
48
+ """Inverse of _key. Returns (layer_id, expert_id, param_name) or None."""
49
+ if KEY_SEP not in key:
50
+ return None
51
+ prefix, param_name = key.split(KEY_SEP, 1)
52
+ if not (prefix.startswith("l") and "_e" in prefix):
53
+ return None
54
+ try:
55
+ layer_str, expert_str = prefix[1:].split("_e", 1)
56
+ return int(layer_str), int(expert_str), param_name
57
+ except ValueError:
58
+ return None
59
+
60
+
61
+ @dataclass
62
+ class ClusterManifest:
63
+ """Parsed cluster_manifest.json."""
64
+ version: int
65
+ num_experts: int
66
+ num_layers: int
67
+ storage_format: str
68
+ # cluster_id -> (filename, expert_ids)
69
+ clusters: Dict[int, Tuple[str, List[int]]]
70
+ # expert_id -> cluster_id
71
+ expert_to_cluster: Dict[int, int]
72
+
73
+ @classmethod
74
+ def from_dict(cls, data: Dict) -> "ClusterManifest":
75
+ clusters: Dict[int, Tuple[str, List[int]]] = {}
76
+ for entry in data["clusters"]:
77
+ clusters[int(entry["cluster_id"])] = (
78
+ entry["file"],
79
+ [int(e) for e in entry["experts"]],
80
+ )
81
+ e2c = {int(k): int(v) for k, v in data["expert_to_cluster"].items()}
82
+ return cls(
83
+ version=int(data.get("version", MANIFEST_VERSION)),
84
+ num_experts=int(data["num_experts"]),
85
+ num_layers=int(data["num_layers"]),
86
+ storage_format=data.get("storage_format", "safetensors"),
87
+ clusters=clusters,
88
+ expert_to_cluster=e2c,
89
+ )
90
+
91
+ def to_dict(self) -> Dict:
92
+ return {
93
+ "version": self.version,
94
+ "num_experts": self.num_experts,
95
+ "num_layers": self.num_layers,
96
+ "storage_format": self.storage_format,
97
+ "clusters": [
98
+ {"cluster_id": cid, "file": fname, "experts": list(eids)}
99
+ for cid, (fname, eids) in sorted(self.clusters.items())
100
+ ],
101
+ "expert_to_cluster": {str(k): v for k, v in self.expert_to_cluster.items()},
102
+ }
103
+
104
+ def cluster_members(self, expert_id: int) -> List[int]:
105
+ cid = self.expert_to_cluster[expert_id]
106
+ return list(self.clusters[cid][1])
107
+
108
+ def cluster_file(self, cluster_id: int) -> str:
109
+ return self.clusters[cluster_id][0]
110
+
111
+
112
+ class ClusterStorage:
113
+ """
114
+ Reader/writer for cluster-packed expert weights.
115
+
116
+ Write path (one-shot, offline):
117
+ ClusterStorage.write_clusters(
118
+ moe_layers, clusters, output_dir, num_layers, num_experts
119
+ )
120
+
121
+ Read path (runtime, hot loop):
122
+ storage = ClusterStorage(output_dir)
123
+ per_layer = storage.load_cluster(cluster_id, dtype=torch.float16)
124
+ # per_layer: {expert_id: {layer_id: {param_name: Tensor}}}
125
+ """
126
+
127
+ def __init__(self, root_dir: str):
128
+ if not _HAS_SAFETENSORS:
129
+ raise ImportError(
130
+ "ClusterStorage requires the `safetensors` package. "
131
+ "Install with `pip install safetensors>=0.4.0`."
132
+ )
133
+ self.root_dir = root_dir
134
+ manifest_path = os.path.join(root_dir, MANIFEST_FILENAME)
135
+ if not os.path.exists(manifest_path):
136
+ raise FileNotFoundError(
137
+ f"cluster_manifest.json not found at {manifest_path}. "
138
+ "Run offload_all_to_ssd() or repack_expert_weights_safetensors() first."
139
+ )
140
+ with open(manifest_path) as f:
141
+ self.manifest = ClusterManifest.from_dict(json.load(f))
142
+
143
+ # ── read path ────────────────────────────────────────────────
144
+
145
+ def load_cluster(
146
+ self,
147
+ cluster_id: int,
148
+ dtype: Optional[torch.dtype] = None,
149
+ ) -> Dict[int, Dict[int, Dict[str, torch.Tensor]]]:
150
+ """
151
+ mmap a whole cluster file and return nested dict:
152
+ {expert_id: {layer_id: {param_name: Tensor}}}
153
+
154
+ Tensors are materialized by safetensors (CPU, target dtype). We keep
155
+ the logical layout compatible with the existing RAM buffer structure
156
+ used by ExpertStore so the refactor is a drop-in replacement.
157
+ """
158
+ file_name = self.manifest.cluster_file(cluster_id)
159
+ path = os.path.join(self.root_dir, file_name)
160
+ nested: Dict[int, Dict[int, Dict[str, torch.Tensor]]] = {}
161
+ with _st_safe_open(path, framework="pt", device="cpu") as f:
162
+ for key in f.keys():
163
+ parsed = _parse_key(key)
164
+ if parsed is None:
165
+ continue
166
+ layer_id, expert_id, param_name = parsed
167
+ t = f.get_tensor(key)
168
+ if dtype is not None and t.dtype != dtype:
169
+ t = t.to(dtype)
170
+ nested.setdefault(expert_id, {}).setdefault(layer_id, {})[param_name] = t
171
+ return nested
172
+
173
+ def cluster_for_expert(self, expert_id: int) -> int:
174
+ return self.manifest.expert_to_cluster[expert_id]
175
+
176
+ def experts_in_cluster(self, cluster_id: int) -> List[int]:
177
+ return list(self.manifest.clusters[cluster_id][1])
178
+
179
+ # ── write path ───────────────────────────────────────────────
180
+
181
+ @staticmethod
182
+ def write_clusters(
183
+ moe_layers,
184
+ clusters: List[List[int]],
185
+ output_dir: str,
186
+ num_layers: int,
187
+ num_experts: int,
188
+ dtype: torch.dtype = torch.float16,
189
+ ) -> str:
190
+ """
191
+ Serialize `moe_layers` (list of ChronosMOEFeedForward) into one
192
+ safetensors file per cluster and write the manifest.
193
+
194
+ Returns the path to cluster_manifest.json.
195
+ """
196
+ if not _HAS_SAFETENSORS:
197
+ raise ImportError(
198
+ "write_clusters requires the `safetensors` package. "
199
+ "Install with `pip install safetensors>=0.4.0`."
200
+ )
201
+ os.makedirs(output_dir, exist_ok=True)
202
+
203
+ manifest_clusters: Dict[int, Tuple[str, List[int]]] = {}
204
+ expert_to_cluster: Dict[int, int] = {}
205
+
206
+ for cid, cluster in enumerate(clusters):
207
+ tensors: Dict[str, torch.Tensor] = {}
208
+ for eid in cluster:
209
+ expert_to_cluster[int(eid)] = cid
210
+ for li in range(num_layers):
211
+ if li >= len(moe_layers):
212
+ continue
213
+ expert = moe_layers[li].experts[eid]
214
+ for pname, pval in expert.state_dict().items():
215
+ tensors[_key(li, int(eid), pname)] = pval.detach().to(dtype=dtype).cpu().contiguous()
216
+
217
+ file_name = f"cluster_{cid}.ctsr"
218
+ file_path = os.path.join(output_dir, file_name)
219
+ _st_save_file(tensors, file_path)
220
+ manifest_clusters[cid] = (file_name, [int(e) for e in cluster])
221
+
222
+ manifest = ClusterManifest(
223
+ version=MANIFEST_VERSION,
224
+ num_experts=num_experts,
225
+ num_layers=num_layers,
226
+ storage_format="safetensors",
227
+ clusters=manifest_clusters,
228
+ expert_to_cluster=expert_to_cluster,
229
+ )
230
+ manifest_path = os.path.join(output_dir, MANIFEST_FILENAME)
231
+ with open(manifest_path, "w") as f:
232
+ json.dump(manifest.to_dict(), f, indent=2)
233
+ return manifest_path
234
+
235
+ @staticmethod
236
+ def has_manifest(root_dir: str) -> bool:
237
+ return os.path.exists(os.path.join(root_dir, MANIFEST_FILENAME))
@@ -0,0 +1,32 @@
1
+ """
2
+ chronos/mlx/__init__.py
3
+
4
+ MLX-native backend for Project Chronos (Apple Silicon / Metal).
5
+
6
+ MLX uses unified memory — CPU and GPU share the same physical DRAM.
7
+ There is no H2D copy, no pinned memory, and no CUDA streams.
8
+ The "SSD prefetch" concept maps differently:
9
+ - Tier 0 (hot): mx.array on GPU (materialised via mx.eval())
10
+ - Tier 1 (warm): mx.array in unified memory (not yet eval'd)
11
+ - Tier 2 (cold): .safetensors / .npy files on SSD
12
+
13
+ Key differences from the CUDA backend:
14
+ - No ExpertStore.promote_to_vram() — mx.eval() pins to Metal on first use
15
+ - No H2D stream — Metal handles async via its own command queue
16
+ - torch.compile unavailable; mx.compile() used instead
17
+ - All modules are mlx.nn.Module, not torch.nn.Module
18
+ """
19
+ from chronos.mlx.model import ChronosMLXModel
20
+ from chronos.mlx.moe import ChronosMLXMOE
21
+ from chronos.mlx.attention import MLAAttentionMLX, SlidingWindowAttentionMLX
22
+ from chronos.mlx.expert_store import MLXExpertStore
23
+ from chronos.mlx.inference import ChronosMLXInferenceEngine
24
+
25
+ __all__ = [
26
+ "ChronosMLXModel",
27
+ "ChronosMLXMOE",
28
+ "MLAAttentionMLX",
29
+ "SlidingWindowAttentionMLX",
30
+ "MLXExpertStore",
31
+ "ChronosMLXInferenceEngine",
32
+ ]
@@ -0,0 +1,230 @@
1
+ """
2
+ chronos/mlx/attention.py — MLX-native MLA + Sliding Window Attention.
3
+
4
+ Design notes vs. the PyTorch version:
5
+ - No past_key_values list; instead we carry (c_kv_cache, k_rope_cache) as
6
+ plain mx.array and concatenate explicitly each step.
7
+ - RoPE: computed with mx operations; no separate apply_rotary_pos_emb import.
8
+ - SlidingWindow: slice oldest tokens with array indexing, not assignment.
9
+ - mx.compile()-friendly: no Python branches on runtime tensor shapes.
10
+ """
11
+ import math
12
+ import mlx.core as mx
13
+ import mlx.nn as nn
14
+
15
+
16
+ def _rope_freqs(dim: int, max_len: int = 4096, base: float = 10000.0) -> mx.array:
17
+ """Precompute RoPE cos/sin tables [max_len, dim/2].
18
+
19
+ `dim` is rounded down to the nearest even number internally so the
20
+ cos/sin half-dimension is well-defined. Callers that pass an odd
21
+ `dim` get tables that match the even portion only.
22
+ """
23
+ even = dim - (dim % 2)
24
+ half = max(even // 2, 1)
25
+ theta = 1.0 / (base ** (mx.arange(0, half, dtype=mx.float32) / half))
26
+ pos = mx.arange(max_len, dtype=mx.float32)
27
+ freqs = mx.outer(pos, theta) # [T, half]
28
+ return mx.cos(freqs), mx.sin(freqs) # each [T, half]
29
+
30
+
31
+ def _apply_rope(x: mx.array, cos: mx.array, sin: mx.array) -> mx.array:
32
+ """
33
+ Apply RoPE to x [B, T, nH, d].
34
+ cos/sin: [T, d_eff/2] where d_eff = d rounded down to even.
35
+
36
+ When d is odd, the trailing element is passed through unchanged so the
37
+ output shape matches the input. (Some Chronos checkpoints were saved
38
+ with rope_dim = head_dim - 1, which is odd whenever head_dim is even
39
+ — head_dim = hidden_size / num_heads = 256/8 = 32 → rope_dim = 31.)
40
+ """
41
+ B, T, nH, d = x.shape
42
+ d_eff = d - (d % 2)
43
+ half = d_eff // 2
44
+ x1 = x[..., :half] # [B, T, nH, half]
45
+ x2 = x[..., half:d_eff] # [B, T, nH, half]
46
+ c = cos[:T, :half][:, None, :] # [T, 1, half]
47
+ s = sin[:T, :half][:, None, :]
48
+ rotated = mx.concatenate([x1 * c - x2 * s,
49
+ x1 * s + x2 * c], axis=-1) # [B, T, nH, d_eff]
50
+ if d_eff < d:
51
+ # Append the unrotated tail so the output keeps the original last dim.
52
+ tail = x[..., d_eff:]
53
+ rotated = mx.concatenate([rotated, tail], axis=-1)
54
+ return rotated
55
+
56
+
57
+ class MLAAttentionMLX(nn.Module):
58
+ """
59
+ Multi-head Latent Attention for MLX.
60
+
61
+ KV cache stores (c_kv [B, T, kv_latent_dim], k_rope [B, T, nH, rope_dim]).
62
+ Both are concatenated each decode step — no copy in/out of VRAM.
63
+ """
64
+
65
+ def __init__(self, config):
66
+ super().__init__()
67
+ self.n_heads = config.num_attention_heads
68
+ self.n_kv_heads = config.num_key_value_heads or config.n_heads
69
+ self.head_dim = config.head_dim
70
+ self.kv_latent_dim = getattr(config, 'kv_latent_dim', 64)
71
+ # rope_dim is clamped to ≤ head_dim−1 to leave room for nope. The
72
+ # MLX _apply_rope below handles odd rope_dim safely (trailing element
73
+ # passes through unchanged), so there's no evenness requirement.
74
+ self.rope_dim = min(
75
+ max(1, getattr(config, 'rope_dim', min(64, self.head_dim))),
76
+ self.head_dim - 1,
77
+ )
78
+ self.nope_dim = self.head_dim - self.rope_dim
79
+ H = config.hidden_size
80
+
81
+ self.q_nope_proj = nn.Linear(H, self.n_heads * self.nope_dim, bias=False)
82
+ self.q_rope_proj = nn.Linear(H, self.n_heads * self.rope_dim, bias=False)
83
+ self.kv_down_proj = nn.Linear(H, self.kv_latent_dim, bias=False)
84
+ self.k_nope_proj = nn.Linear(self.kv_latent_dim, self.n_kv_heads * self.nope_dim, bias=False)
85
+ self.k_rope_proj = nn.Linear(self.kv_latent_dim, self.n_kv_heads * self.rope_dim, bias=False)
86
+ self.v_proj = nn.Linear(self.kv_latent_dim, self.n_kv_heads * self.head_dim, bias=False)
87
+ self.o_proj = nn.Linear(self.n_heads * self.head_dim, H, bias=False)
88
+
89
+ cos, sin = _rope_freqs(self.rope_dim)
90
+ self.cos = cos # [max_len, rope_dim/2] — not a parameter
91
+ self.sin = sin
92
+
93
+ def __call__(
94
+ self,
95
+ x: mx.array,
96
+ mask: mx.array = None,
97
+ cache=None, # (c_kv [B, T_past, kv_latent_dim], k_rope [B, T_past, nH, rope_dim])
98
+ ):
99
+ B, S, H = x.shape
100
+
101
+ # ── Queries ───────────────────────────────────────────────
102
+ q_nope = self.q_nope_proj(x).reshape(B, S, self.n_heads, self.nope_dim)
103
+ q_rope = self.q_rope_proj(x).reshape(B, S, self.n_heads, self.rope_dim)
104
+
105
+ # ── KV latent (what gets cached) ──────────────────────────
106
+ c_kv = self.kv_down_proj(x) # [B, S, kv_latent_dim]
107
+
108
+ # Append to cache
109
+ if cache is not None:
110
+ c_kv_past, k_rope_past = cache
111
+ c_kv_full = mx.concatenate([c_kv_past, c_kv], axis=1)
112
+ else:
113
+ c_kv_full = c_kv
114
+ k_rope_past = None
115
+
116
+ T_full = c_kv_full.shape[1]
117
+
118
+ # ── Keys / Values (recomputed from cache) ─────────────────
119
+ k_nope = self.k_nope_proj(c_kv_full).reshape(B, T_full, self.n_kv_heads, self.nope_dim)
120
+ k_rope = self.k_rope_proj(c_kv_full).reshape(B, T_full, self.n_kv_heads, self.rope_dim)
121
+ v = self.v_proj(c_kv_full).reshape(B, T_full, self.n_kv_heads, self.head_dim)
122
+
123
+ # ── RoPE ──────────────────────────────────────────────────
124
+ past_len = T_full - S
125
+ q_rope = _apply_rope(q_rope,
126
+ self.cos[past_len:past_len + S],
127
+ self.sin[past_len:past_len + S])
128
+ k_rope = _apply_rope(k_rope, self.cos[:T_full], self.sin[:T_full])
129
+
130
+ # Concat nope + rope
131
+ q = mx.concatenate([q_nope, q_rope], axis=-1) # [B, S, nH, head_dim]
132
+ k = mx.concatenate([k_nope, k_rope], axis=-1) # [B, T, nKV, head_dim]
133
+
134
+ # GQA repeat
135
+ if self.n_heads != self.n_kv_heads:
136
+ rep = self.n_heads // self.n_kv_heads
137
+ k = mx.repeat(k, rep, axis=2)
138
+ v = mx.repeat(v, rep, axis=2)
139
+
140
+ # ── Attention ─────────────────────────────────────────────
141
+ scale = 1.0 / math.sqrt(self.head_dim)
142
+ q = q.transpose(0, 2, 1, 3) # [B, nH, S, d]
143
+ k = k.transpose(0, 2, 1, 3) # [B, nH, T, d]
144
+ v = v.transpose(0, 2, 1, 3)
145
+ attn = (q @ k.transpose(0, 1, 3, 2)) * scale # [B, nH, S, T]
146
+ if mask is not None:
147
+ attn = attn + mask[None, None, :, :]
148
+ attn = mx.softmax(attn.astype(mx.float32), axis=-1).astype(q.dtype)
149
+ out = (attn @ v).transpose(0, 2, 1, 3).reshape(B, S, -1)
150
+
151
+ new_cache = (c_kv_full, k_rope)
152
+ return self.o_proj(out), new_cache
153
+
154
+
155
+ class SlidingWindowAttentionMLX(nn.Module):
156
+ """
157
+ Standard multi-head attention with a sliding KV window for MLX.
158
+ KV cache is capped at window_size tokens — O(1) memory per step.
159
+ """
160
+
161
+ def __init__(self, config):
162
+ super().__init__()
163
+ self.n_heads = config.num_attention_heads
164
+ self.n_kv_heads = config.num_key_value_heads or config.n_heads
165
+ self.head_dim = config.head_dim
166
+ self.window_size = getattr(config, 'sliding_window_size', 2048)
167
+ H = config.hidden_size
168
+
169
+ self.q_proj = nn.Linear(H, self.n_heads * self.head_dim, bias=False)
170
+ self.k_proj = nn.Linear(H, self.n_kv_heads * self.head_dim, bias=False)
171
+ self.v_proj = nn.Linear(H, self.n_kv_heads * self.head_dim, bias=False)
172
+ self.o_proj = nn.Linear(self.n_heads * self.head_dim, H, bias=False)
173
+
174
+ rope_dim = self.head_dim
175
+ cos, sin = _rope_freqs(rope_dim)
176
+ self.cos = cos
177
+ self.sin = sin
178
+
179
+ def __call__(
180
+ self,
181
+ x: mx.array,
182
+ mask: mx.array = None,
183
+ cache=None, # (k [B, T_past, nKV, d], v [B, T_past, nKV, d])
184
+ ):
185
+ B, S, H = x.shape
186
+
187
+ q = self.q_proj(x).reshape(B, S, self.n_heads, self.head_dim)
188
+ k = self.k_proj(x).reshape(B, S, self.n_kv_heads, self.head_dim)
189
+ v = self.v_proj(x).reshape(B, S, self.n_kv_heads, self.head_dim)
190
+
191
+ if cache is not None:
192
+ k_past, v_past = cache
193
+ k = mx.concatenate([k_past, k], axis=1)
194
+ v = mx.concatenate([v_past, v], axis=1)
195
+
196
+ T_full = k.shape[1]
197
+ past_len = T_full - S
198
+
199
+ # Apply RoPE to new queries and all keys (before GQA repeat)
200
+ q = _apply_rope(q, self.cos[past_len:past_len + S], self.sin[past_len:past_len + S])
201
+ k = _apply_rope(k, self.cos[:T_full], self.sin[:T_full])
202
+
203
+ # Slide window — keep only last window_size tokens
204
+ if T_full > self.window_size:
205
+ k = k[:, -self.window_size:, :, :]
206
+ v = v[:, -self.window_size:, :, :]
207
+
208
+ # GQA repeat for attention (k/v expand to n_heads)
209
+ k_attn = k
210
+ v_attn = v
211
+ if self.n_heads != self.n_kv_heads:
212
+ rep = self.n_heads // self.n_kv_heads
213
+ k_attn = mx.repeat(k, rep, axis=2)
214
+ v_attn = mx.repeat(v, rep, axis=2)
215
+
216
+ scale = 1.0 / math.sqrt(self.head_dim)
217
+ # Transpose to [B, nH, T, d] for batched matmul
218
+ qt = q.transpose(0, 2, 1, 3)
219
+ kt = k_attn.transpose(0, 2, 1, 3)
220
+ vt = v_attn.transpose(0, 2, 1, 3)
221
+ attn = (qt @ kt.transpose(0, 1, 3, 2)) * scale
222
+ if mask is not None:
223
+ # mask is [S, T_k] (2D causal); broadcast over [B, nH, S, T_k]
224
+ attn = attn + mask[None, None, :, :]
225
+ attn = mx.softmax(attn.astype(mx.float32), axis=-1).astype(q.dtype)
226
+ out = (attn @ vt).transpose(0, 2, 1, 3).reshape(B, S, -1)
227
+
228
+ # Cache stores [B, T, nKV, d] (pre-repeat, pre-transpose)
229
+ new_cache = (k, v)
230
+ return self.o_proj(out), new_cache
@@ -0,0 +1,155 @@
1
+ """
2
+ chronos/mlx/expert_store.py — Expert weight management for MLX (unified memory).
3
+
4
+ On Apple Silicon there is no VRAM / RAM split — all memory is unified.
5
+ "Prefetch" means loading from SSD into Python-side mx.array objects and
6
+ calling mx.eval() to materialise them in Metal shared memory.
7
+
8
+ Three logical tiers:
9
+ Tier 0 (hot): mx.array already eval'd (resident in Metal)
10
+ Tier 1 (warm): mx.array allocated but not yet eval'd
11
+ Tier 2 (cold): weights stored as .npy / .pt files on SSD
12
+ """
13
+ import os
14
+ import threading
15
+ import collections
16
+ from typing import Dict, List, Optional
17
+
18
+ import mlx.core as mx
19
+ import numpy as np
20
+
21
+
22
+ class MLXExpertStore:
23
+ """
24
+ Expert weight store for MLX (unified memory — no H2D stream needed).
25
+
26
+ Public API is intentionally similar to ExpertStore (PyTorch) so
27
+ CacheManager can swap backends.
28
+ """
29
+
30
+ def __init__(self, model, config, ssd_dir: str = "./expert_cache_mlx"):
31
+ self.config = config
32
+ self.ssd_dir = ssd_dir
33
+ self.num_experts = config.num_experts
34
+ os.makedirs(ssd_dir, exist_ok=True)
35
+
36
+ from chronos.mlx.moe import ChronosMLXMOE
37
+ self.moe_layers: List[ChronosMLXMOE] = [
38
+ layer.mlp for layer in model.layers
39
+ if isinstance(layer.mlp, ChronosMLXMOE)
40
+ ]
41
+ self.num_layers = len(self.moe_layers)
42
+
43
+ # LRU tracking which experts are "hot" (eval'd in Metal)
44
+ capacity = max(1, int(config.vram_budget_gb * 1.5)) # rough heuristic
45
+ self._hot_lru: collections.OrderedDict = collections.OrderedDict()
46
+ self._capacity = capacity
47
+ self._lock = threading.Lock()
48
+
49
+ # Warm buffers: {expert_id: {layer_idx: {param_name: mx.array}}}
50
+ self._warm: Dict[int, Dict[int, Dict[str, mx.array]]] = {}
51
+
52
+ # ── SSD serialisation ─────────────────────────────────────────
53
+
54
+ def offload_all_to_ssd(self):
55
+ """Save all expert weights to SSD as .npy files."""
56
+ for li, moe in enumerate(self.moe_layers):
57
+ for ei, expert in enumerate(moe.experts):
58
+ for name, param in vars(expert).items():
59
+ if isinstance(param, mx.array):
60
+ path = self._ssd_path(ei, li, name)
61
+ if not os.path.exists(path):
62
+ np.save(path, np.array(param))
63
+
64
+ def _ssd_path(self, expert_id: int, layer_idx: int, param_name: str) -> str:
65
+ return os.path.join(
66
+ self.ssd_dir, f"expert_l{layer_idx}_e{expert_id}_{param_name}.npy"
67
+ )
68
+
69
+ def _load_from_ssd(self, expert_id: int) -> Dict[int, Dict[str, mx.array]]:
70
+ layer_states = {}
71
+ for li in range(self.num_layers):
72
+ params = {}
73
+ for name in ("gate_proj", "up_proj", "down_proj"):
74
+ for subname in ("weight",):
75
+ fname = f"{name}.{subname}"
76
+ path = self._ssd_path(expert_id, li, fname)
77
+ if os.path.exists(path):
78
+ params[fname] = mx.array(np.load(path))
79
+ if params:
80
+ layer_states[li] = params
81
+ return layer_states
82
+
83
+ # ── Prefetch (SSD → unified memory) ──────────────────────────
84
+
85
+ def prefetch_to_ram(self, expert_ids: List[int]):
86
+ """
87
+ Load expert weights from SSD into mx.array (not yet eval'd).
88
+ Call from a background thread for async prefetch.
89
+ """
90
+ for eid in expert_ids:
91
+ with self._lock:
92
+ if eid in self._warm:
93
+ continue
94
+ layer_states = self._load_from_ssd(eid)
95
+ if not layer_states:
96
+ continue
97
+ with self._lock:
98
+ self._warm[eid] = layer_states
99
+
100
+ # ── Promote to "hot" (materialise in Metal) ───────────────────
101
+
102
+ def promote_to_vram(self, expert_id: int) -> bool:
103
+ """
104
+ Materialise expert weights in Metal shared memory via mx.eval().
105
+ On MLX there is no actual copy — just lazy evaluation.
106
+ """
107
+ with self._lock:
108
+ if expert_id not in self._warm:
109
+ return False
110
+ layer_states = self._warm[expert_id]
111
+
112
+ # mx.eval() forces computation and pins arrays in Metal memory
113
+ for li, params in layer_states.items():
114
+ mx.eval(*params.values())
115
+ # Write back into the live model
116
+ expert = self.moe_layers[li].experts[expert_id]
117
+ for fname, arr in params.items():
118
+ parts = fname.split(".")
119
+ obj = expert
120
+ for part in parts[:-1]:
121
+ obj = getattr(obj, part)
122
+ setattr(obj, parts[-1], arr)
123
+
124
+ with self._lock:
125
+ if expert_id in self._hot_lru:
126
+ self._hot_lru.move_to_end(expert_id)
127
+ else:
128
+ if len(self._hot_lru) >= self._capacity:
129
+ self._hot_lru.popitem(last=False)
130
+ self._hot_lru[expert_id] = True
131
+ return True
132
+
133
+ def sync_h2d(self):
134
+ """No-op on MLX — Metal handles synchronisation internally."""
135
+ pass
136
+
137
+ # ── Availability mask ─────────────────────────────────────────
138
+
139
+ def vram_availability_mask(self) -> mx.array:
140
+ """[num_experts] float32 mask — 1.0 if expert is hot, 0.0 otherwise."""
141
+ mask = np.zeros(self.num_experts, dtype=np.float32)
142
+ with self._lock:
143
+ for eid in self._hot_lru:
144
+ if eid < self.num_experts:
145
+ mask[eid] = 1.0
146
+ return mx.array(mask)
147
+
148
+ def stats(self) -> dict:
149
+ return {
150
+ "backend": "mlx",
151
+ "hot_experts": len(self._hot_lru),
152
+ "hot_capacity": self._capacity,
153
+ "warm_experts": len(self._warm),
154
+ "h2d_stream": "metal_unified",
155
+ }