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
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))
|
chronos/mlx/__init__.py
ADDED
|
@@ -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
|
+
]
|
chronos/mlx/attention.py
ADDED
|
@@ -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
|
+
}
|