Project-Chronos 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. chronos/__init__.py +16 -0
  2. chronos/_backend_legacy.py +64 -0
  3. chronos/app.py +122 -0
  4. chronos/backend/__init__.py +40 -0
  5. chronos/backend/dispatcher.py +347 -0
  6. chronos/backend/ext/__init__.py +2 -0
  7. chronos/backend/ext/opencl.py +22 -0
  8. chronos/cli.py +62 -0
  9. chronos/data/__init__.py +0 -0
  10. chronos/data/flexible_dataset.py +309 -0
  11. chronos/deps.py +72 -0
  12. chronos/eval/__init__.py +2 -0
  13. chronos/eval/benchmark.py +159 -0
  14. chronos/eval/io_profiler.py +232 -0
  15. chronos/io/__init__.py +13 -0
  16. chronos/io/async_prefetcher.py +220 -0
  17. chronos/io/cluster_layout.py +317 -0
  18. chronos/io/expert_store.py +559 -0
  19. chronos/io/io_simulator.py +40 -0
  20. chronos/io/storage.py +237 -0
  21. chronos/mlx/__init__.py +32 -0
  22. chronos/mlx/attention.py +230 -0
  23. chronos/mlx/expert_store.py +155 -0
  24. chronos/mlx/inference.py +159 -0
  25. chronos/mlx/model.py +181 -0
  26. chronos/mlx/moe.py +102 -0
  27. chronos/model/__init__.py +6 -0
  28. chronos/model/config.py +54 -0
  29. chronos/model/hf_io.py +149 -0
  30. chronos/model/hybrid_attention.py +237 -0
  31. chronos/model/lookahead_router.py +55 -0
  32. chronos/model/model_chronos.py +271 -0
  33. chronos/model/moe_chronos.py +189 -0
  34. chronos/model/temporal_loss.py +124 -0
  35. chronos/router/__init__.py +12 -0
  36. chronos/router/expert_predictor.py +163 -0
  37. chronos/router/intent_classifier.py +193 -0
  38. chronos/router/prefill_scheduler.py +248 -0
  39. chronos/runtime/__init__.py +2 -0
  40. chronos/runtime/cache_manager.py +117 -0
  41. chronos/runtime/inference_engine.py +295 -0
  42. chronos/runtime/metrics.py +75 -0
  43. chronos/serving/__init__.py +13 -0
  44. chronos/serving/vllm_adapter.py +85 -0
  45. chronos/trainer/__init__.py +6 -0
  46. chronos/trainer/chronos_trainer.py +151 -0
  47. chronos/trainer/distill_trainer.py +193 -0
  48. chronos/trainer/dpo_trainer.py +190 -0
  49. chronos/trainer/grpo_trainer.py +288 -0
  50. chronos/trainer/loss_mixin.py +165 -0
  51. chronos/trainer/optim_utils.py +101 -0
  52. chronos/trainer/orpo_trainer.py +189 -0
  53. chronos/trainer/reward.py +130 -0
  54. chronos/trainer/sft_trainer.py +189 -0
  55. chronos/tuning/__init__.py +1 -0
  56. chronos/tuning/_base_tuner.py +284 -0
  57. chronos/tuning/chronos_auto_tuner.py +203 -0
  58. project_chronos-0.1.1.dist-info/METADATA +670 -0
  59. project_chronos-0.1.1.dist-info/RECORD +83 -0
  60. project_chronos-0.1.1.dist-info/WHEEL +5 -0
  61. project_chronos-0.1.1.dist-info/entry_points.txt +7 -0
  62. project_chronos-0.1.1.dist-info/licenses/LICENSE +177 -0
  63. project_chronos-0.1.1.dist-info/top_level.txt +8 -0
  64. train_chronos.py +133 -0
  65. train_chronos_distill.py +95 -0
  66. train_chronos_dpo.py +82 -0
  67. train_chronos_grpo.py +88 -0
  68. train_chronos_orpo.py +81 -0
  69. train_chronos_sft.py +85 -0
  70. ui/__init__.py +3 -0
  71. ui/estimator.py +204 -0
  72. ui/gradio_compat.py +72 -0
  73. ui/i18n.py +540 -0
  74. ui/presets.py +194 -0
  75. ui/tabs/__init__.py +3 -0
  76. ui/tabs/autotune_tab.py +210 -0
  77. ui/tabs/benchmark_tab.py +196 -0
  78. ui/tabs/config_tab.py +318 -0
  79. ui/tabs/data_tab.py +148 -0
  80. ui/tabs/inference_tab.py +496 -0
  81. ui/tabs/iomon_tab.py +37 -0
  82. ui/tabs/pipeline_tab.py +134 -0
  83. ui/tabs/train_tab.py +1688 -0
@@ -0,0 +1,309 @@
1
+ """
2
+ chronos/data/flexible_dataset.py
3
+
4
+ Streaming JSONL loaders with byte-offset indexing. Three variants cover
5
+ the six Chronos training stages without ever loading the file into RAM:
6
+
7
+ FlexibleDataset — pretrain / language modeling. Auto-detects
8
+ common text-field schemas (text, content,
9
+ instruction+output, conversations, messages).
10
+ StreamingSFTDataset — supervised fine-tuning. Uses the tokenizer's
11
+ chat template; only assistant tokens contribute
12
+ to the loss (label = -100 elsewhere). Drop-in
13
+ for minimind's SFTDataset.
14
+ StreamingDPODataset — preference pairs. Returns the dict shape DPO /
15
+ ORPO trainers expect.
16
+
17
+ Each dataset stores only `offsets: list[int]` (8 bytes/sample) plus the
18
+ file path. `__getitem__` does seek+readline+json.loads on demand, so a
19
+ multi-GB corpus uses < 1 GB of resident memory.
20
+
21
+ The file handle is opened lazily per process (re-opens after fork so
22
+ DataLoader num_workers > 0 still works) and protected by a thread lock
23
+ so num_workers = 0 paths with concurrent access are safe.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import threading
30
+
31
+ import torch
32
+ from torch.utils.data import Dataset
33
+
34
+
35
+ # ── Field-name auto-detection (FlexibleDataset only) ──────────────────
36
+
37
+ _TEXT_KEYS = ("text", "content", "story", "passage", "document", "article")
38
+ _PAIR_KEYS = (
39
+ ("instruction", "output"),
40
+ ("instruction", "response"),
41
+ ("prompt", "response"),
42
+ ("prompt", "completion"),
43
+ ("input", "output"),
44
+ ("question", "answer"),
45
+ ("query", "answer"),
46
+ )
47
+
48
+
49
+ def _extract_text(record: dict) -> str:
50
+ for k in _TEXT_KEYS:
51
+ if k in record and record[k]:
52
+ return str(record[k])
53
+ for k1, k2 in _PAIR_KEYS:
54
+ if k1 in record and k2 in record:
55
+ parts = [str(record[k1]).strip(), str(record[k2]).strip()]
56
+ return "\n".join(p for p in parts if p)
57
+ if "conversations" in record and isinstance(record["conversations"], list):
58
+ return " ".join(
59
+ str(c.get("value", c.get("content", "")))
60
+ for c in record["conversations"]
61
+ )
62
+ if "messages" in record and isinstance(record["messages"], list):
63
+ return " ".join(str(m.get("content", "")) for m in record["messages"])
64
+ parts = [str(v) for v in record.values()
65
+ if isinstance(v, (str, int, float)) and str(v).strip()]
66
+ return " ".join(parts)
67
+
68
+
69
+ # ── Shared streaming infrastructure ───────────────────────────────────
70
+
71
+ class _StreamingJSONLBase(Dataset):
72
+ """Base class providing byte-offset index + lazy per-process file handle."""
73
+
74
+ def __init__(self, data_path: str):
75
+ self.data_path = os.path.abspath(data_path)
76
+ self.offsets: list[int] = []
77
+ with open(self.data_path, "rb") as f:
78
+ pos = 0
79
+ while True:
80
+ line = f.readline()
81
+ if not line:
82
+ break
83
+ if line.strip():
84
+ self.offsets.append(pos)
85
+ pos = f.tell()
86
+ if not self.offsets:
87
+ raise ValueError(f"No JSONL records in {data_path}")
88
+ self._fh = None
89
+ self._fh_lock = threading.Lock()
90
+
91
+ def _get_fh(self):
92
+ pid = os.getpid()
93
+ if self._fh is None or getattr(self._fh, "_chronos_pid", None) != pid:
94
+ if self._fh is not None:
95
+ try:
96
+ self._fh.close()
97
+ except Exception:
98
+ pass
99
+ self._fh = open(self.data_path, "rb")
100
+ self._fh._chronos_pid = pid
101
+ return self._fh
102
+
103
+ def _read_record(self, index: int) -> dict:
104
+ offset = self.offsets[index]
105
+ with self._fh_lock:
106
+ fh = self._get_fh()
107
+ fh.seek(offset)
108
+ raw = fh.readline()
109
+ return json.loads(raw.decode("utf-8"))
110
+
111
+ def __len__(self):
112
+ return len(self.offsets)
113
+
114
+ def __del__(self):
115
+ try:
116
+ if self._fh is not None:
117
+ self._fh.close()
118
+ except Exception:
119
+ pass
120
+
121
+
122
+ # ── Pretrain / generic LM ────────────────────────────────────────────
123
+
124
+ class FlexibleDataset(_StreamingJSONLBase):
125
+ """Pretrain-style streaming dataset; auto-detects the text field."""
126
+
127
+ def __init__(self, data_path: str, tokenizer, max_length: int = 512):
128
+ super().__init__(data_path)
129
+ self.tokenizer = tokenizer
130
+ self.max_length = max_length
131
+ first = self._read_record(0)
132
+ detected = _extract_text(first)
133
+ field_hint = (
134
+ next((k for k in _TEXT_KEYS if k in first), None)
135
+ or next((f"{k1}+{k2}" for k1, k2 in _PAIR_KEYS
136
+ if k1 in first and k2 in first), None)
137
+ or ("conversations" if "conversations" in first else "auto")
138
+ )
139
+ print(f"[FlexibleDataset] {len(self.offsets)} records (streaming), "
140
+ f"detected format: '{field_hint}', "
141
+ f"sample preview: {detected[:80]!r}")
142
+
143
+ def __getitem__(self, index):
144
+ record = self._read_record(index)
145
+ text = _extract_text(record)
146
+ tok = self.tokenizer(
147
+ text, add_special_tokens=False,
148
+ max_length=self.max_length - 2, truncation=True,
149
+ )
150
+ tokens = (
151
+ [self.tokenizer.bos_token_id]
152
+ + tok.input_ids
153
+ + [self.tokenizer.eos_token_id]
154
+ )
155
+ pad_id = self.tokenizer.pad_token_id or 0
156
+ tokens = tokens + [pad_id] * (self.max_length - len(tokens))
157
+ input_ids = torch.tensor(tokens, dtype=torch.long)
158
+ labels = input_ids.clone()
159
+ labels[input_ids == pad_id] = -100
160
+ return input_ids, labels
161
+
162
+
163
+ # ── SFT (conversations → assistant-token loss mask) ─────────────────
164
+
165
+ class StreamingSFTDataset(_StreamingJSONLBase):
166
+ """Streaming counterpart to minimind's SFTDataset.
167
+
168
+ Reads `conversations: [{role, content, ...}, ...]` records, applies
169
+ the tokenizer's chat template, and masks every position outside an
170
+ assistant turn with -100 so the loss only fires on assistant tokens.
171
+ """
172
+
173
+ def __init__(self, data_path: str, tokenizer, max_length: int = 1024):
174
+ super().__init__(data_path)
175
+ self.tokenizer = tokenizer
176
+ self.max_length = max_length
177
+ # Sentinels marking the assistant turn boundary in encoded form.
178
+ # Same formulation minimind uses (`bos`+'assistant\n' / `eos`+'\n').
179
+ self.bos_id = tokenizer(
180
+ f'{tokenizer.bos_token}assistant\n', add_special_tokens=False
181
+ ).input_ids
182
+ self.eos_id = tokenizer(
183
+ f'{tokenizer.eos_token}\n', add_special_tokens=False
184
+ ).input_ids
185
+ print(f"[StreamingSFTDataset] {len(self.offsets)} records (streaming)")
186
+
187
+ def _build_prompt(self, conversations) -> str:
188
+ messages = []
189
+ tools = None
190
+ for m in conversations:
191
+ m = dict(m)
192
+ if m.get("role") == "system" and m.get("tools"):
193
+ tools = json.loads(m["tools"]) if isinstance(m["tools"], str) else m["tools"]
194
+ if m.get("tool_calls") and isinstance(m["tool_calls"], str):
195
+ m["tool_calls"] = json.loads(m["tool_calls"])
196
+ messages.append(m)
197
+ return self.tokenizer.apply_chat_template(
198
+ messages, tokenize=False, add_generation_prompt=False, tools=tools,
199
+ )
200
+
201
+ def _label_mask(self, input_ids: list[int]) -> list[int]:
202
+ """-100 outside every <bos>assistant\\n ... <eos>\\n span."""
203
+ labels = [-100] * len(input_ids)
204
+ i = 0
205
+ nb = len(self.bos_id)
206
+ ne = len(self.eos_id)
207
+ while i < len(input_ids):
208
+ if input_ids[i:i + nb] == self.bos_id:
209
+ start = i + nb
210
+ end = start
211
+ while end < len(input_ids):
212
+ if input_ids[end:end + ne] == self.eos_id:
213
+ break
214
+ end += 1
215
+ for j in range(start, min(end + ne, self.max_length)):
216
+ labels[j] = input_ids[j]
217
+ i = end + ne if end < len(input_ids) else len(input_ids)
218
+ else:
219
+ i += 1
220
+ return labels
221
+
222
+ def __getitem__(self, index):
223
+ record = self._read_record(index)
224
+ convs = record.get("conversations")
225
+ if not convs:
226
+ # Graceful fall-through for mixed corpora — treat as pretrain.
227
+ text = _extract_text(record)
228
+ ids = self.tokenizer(text, truncation=True,
229
+ max_length=self.max_length).input_ids
230
+ else:
231
+ prompt = self._build_prompt(convs)
232
+ ids = self.tokenizer(prompt).input_ids[:self.max_length]
233
+ pad_id = self.tokenizer.pad_token_id or 0
234
+ ids = ids + [pad_id] * (self.max_length - len(ids))
235
+ labels = self._label_mask(ids)
236
+ return (
237
+ torch.tensor(ids, dtype=torch.long),
238
+ torch.tensor(labels, dtype=torch.long),
239
+ )
240
+
241
+
242
+ # ── DPO / ORPO (chosen + rejected pair) ─────────────────────────────
243
+
244
+ class StreamingDPODataset(_StreamingJSONLBase):
245
+ """Streaming counterpart to minimind's DPODataset.
246
+
247
+ Returns the dict shape the DPO/ORPO trainers expect:
248
+ {x_chosen, y_chosen, mask_chosen, x_rejected, y_rejected, mask_rejected}
249
+ """
250
+
251
+ def __init__(self, data_path: str, tokenizer, max_length: int = 4096):
252
+ super().__init__(data_path)
253
+ self.tokenizer = tokenizer
254
+ self.max_length = max_length
255
+ self.padding = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0
256
+ self.bos_id = tokenizer(
257
+ f'{tokenizer.bos_token}assistant\n', add_special_tokens=False
258
+ ).input_ids
259
+ self.eos_id = tokenizer(
260
+ f'{tokenizer.eos_token}\n', add_special_tokens=False
261
+ ).input_ids
262
+ print(f"[StreamingDPODataset] {len(self.offsets)} pairs (streaming)")
263
+
264
+ def _loss_mask(self, ids: list[int]) -> list[int]:
265
+ mask = [0] * len(ids)
266
+ i = 0
267
+ nb = len(self.bos_id); ne = len(self.eos_id)
268
+ while i < len(ids):
269
+ if ids[i:i + nb] == self.bos_id:
270
+ start = i + nb
271
+ end = start
272
+ while end < len(ids):
273
+ if ids[end:end + ne] == self.eos_id:
274
+ break
275
+ end += 1
276
+ for j in range(start, min(end + ne, self.max_length)):
277
+ mask[j] = 1
278
+ i = end + ne if end < len(ids) else len(ids)
279
+ else:
280
+ i += 1
281
+ return mask
282
+
283
+ def __getitem__(self, index):
284
+ rec = self._read_record(index)
285
+ chosen = rec["chosen"]; rejected = rec["rejected"]
286
+
287
+ chosen_prompt = self.tokenizer.apply_chat_template(
288
+ chosen, tokenize=False, add_generation_prompt=False,
289
+ )
290
+ rejected_prompt = self.tokenizer.apply_chat_template(
291
+ rejected, tokenize=False, add_generation_prompt=False,
292
+ )
293
+
294
+ c_enc = self.tokenizer(chosen_prompt, truncation=True,
295
+ max_length=self.max_length, padding="max_length")
296
+ r_enc = self.tokenizer(rejected_prompt, truncation=True,
297
+ max_length=self.max_length, padding="max_length")
298
+ c_ids = c_enc["input_ids"]
299
+ r_ids = r_enc["input_ids"]
300
+ c_mask = self._loss_mask(c_ids)
301
+ r_mask = self._loss_mask(r_ids)
302
+ return {
303
+ "x_chosen": torch.tensor(c_ids[:-1], dtype=torch.long),
304
+ "y_chosen": torch.tensor(c_ids[1:], dtype=torch.long),
305
+ "mask_chosen": torch.tensor(c_mask[1:], dtype=torch.long),
306
+ "x_rejected": torch.tensor(r_ids[:-1], dtype=torch.long),
307
+ "y_rejected": torch.tensor(r_ids[1:], dtype=torch.long),
308
+ "mask_rejected":torch.tensor(r_mask[1:], dtype=torch.long),
309
+ }
chronos/deps.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ chronos/deps.py — Automatic dependency bootstrap for minimind-master kernel.
3
+
4
+ When minimind-master is not found locally, this module downloads it from
5
+ GitHub and makes it importable. Called automatically on first import of
6
+ any chronos.model module.
7
+
8
+ Legal notice: minimind is licensed under Apache-2.0.
9
+ See THIRD_PARTY_NOTICES.md for full attribution.
10
+ """
11
+ import sys
12
+ import os
13
+ import subprocess
14
+ import importlib
15
+ from pathlib import Path
16
+
17
+ MINIMIND_REPO = "https://github.com/jingyaogong/minimind"
18
+ MINIMIND_COMMIT = "master" # pin to a tag/commit for reproducibility
19
+
20
+ # Candidate local paths (in priority order)
21
+ _CANDIDATE_PATHS = [
22
+ Path(__file__).parent.parent.parent / "minimind-master", # sibling dir
23
+ Path(__file__).parent.parent / "minimind-master",
24
+ Path.home() / ".cache" / "chronos" / "minimind-master",
25
+ ]
26
+
27
+
28
+ def _find_local() -> Path | None:
29
+ for p in _CANDIDATE_PATHS:
30
+ if (p / "model" / "model_minimind.py").exists():
31
+ return p
32
+ return None
33
+
34
+
35
+ def _download(target: Path) -> Path:
36
+ target.parent.mkdir(parents=True, exist_ok=True)
37
+ print(f"[Chronos] minimind not found locally. Downloading from {MINIMIND_REPO} ...")
38
+ print(f"[Chronos] Target: {target}")
39
+ print(f"[Chronos] License: Apache-2.0 | Copyright jingyaogong")
40
+ subprocess.run(
41
+ ["git", "clone", "--depth", "1", "--branch", MINIMIND_COMMIT,
42
+ MINIMIND_REPO, str(target)],
43
+ check=True,
44
+ )
45
+ print(f"[Chronos] minimind downloaded to {target}")
46
+ return target
47
+
48
+
49
+ def ensure_minimind() -> Path:
50
+ """
51
+ Ensure minimind-master is available and on sys.path.
52
+ Returns the path to the minimind root directory.
53
+ """
54
+ local = _find_local()
55
+ if local is None:
56
+ cache_dir = Path.home() / ".cache" / "chronos" / "minimind-master"
57
+ local = _download(cache_dir)
58
+
59
+ root = str(local)
60
+ if root not in sys.path:
61
+ sys.path.insert(0, root)
62
+ return local
63
+
64
+
65
+ def get_tokenizer_path() -> str:
66
+ """Return the path to the minimind tokenizer directory."""
67
+ root = ensure_minimind()
68
+ return str(root / "model")
69
+
70
+
71
+ # Run on import
72
+ _minimind_root = ensure_minimind()
@@ -0,0 +1,2 @@
1
+ from .io_profiler import evaluate_lookahead_accuracy, estimate_cache_hit_rate
2
+ from .benchmark import compute_perplexity, measure_throughput, kv_cache_memory_bytes
@@ -0,0 +1,159 @@
1
+ """
2
+ chronos/eval/benchmark.py
3
+
4
+ Phase 3 end-to-end evaluation:
5
+ - Perplexity on held-out text (accuracy proxy)
6
+ - Throughput (tokens/s) with and without async prefetch
7
+ - KV cache memory usage comparison: standard vs hybrid attention
8
+ - Soft gating fallback rate
9
+
10
+ Usage:
11
+ python -m chronos.eval.benchmark \
12
+ --model_path ./out/chronos_512_moe.pth \
13
+ --data_path ./dataset/eval.jsonl \
14
+ --device cpu \
15
+ --max_new_tokens 64
16
+ """
17
+ import sys
18
+ import chronos.deps # ensure minimind on sys.path
19
+
20
+ import argparse
21
+ import math
22
+ import time
23
+ import torch
24
+ import torch.nn.functional as F
25
+ from torch.utils.data import DataLoader
26
+ from transformers import AutoTokenizer
27
+
28
+ from chronos.model.config import ChronosConfig
29
+ from chronos.model.model_chronos import ChronosForCausalLM
30
+ from chronos.model.hybrid_attention import MLAAttention, SlidingWindowAttention
31
+
32
+
33
+ def compute_perplexity(model, dataloader, device, max_batches=50):
34
+ model.eval()
35
+ total_loss, total_tokens = 0.0, 0
36
+ with torch.no_grad():
37
+ for i, (input_ids, labels) in enumerate(dataloader):
38
+ if i >= max_batches:
39
+ break
40
+ input_ids, labels = input_ids.to(device), labels.to(device)
41
+ out, _ = model(input_ids, labels=labels)
42
+ n_tokens = (labels != -100).sum().item()
43
+ total_loss += out.loss.item() * n_tokens
44
+ total_tokens += n_tokens
45
+ ppl = math.exp(total_loss / max(total_tokens, 1))
46
+ return ppl
47
+
48
+
49
+ def measure_throughput(model, input_ids, device, max_new_tokens=64):
50
+ model.eval()
51
+ input_ids = input_ids.to(device)
52
+ t0 = time.monotonic()
53
+ with torch.no_grad():
54
+ out, lp = model(input_ids, use_cache=True)
55
+ past_kv = out.past_key_values
56
+ for _ in range(max_new_tokens):
57
+ x = torch.randint(0, model.config.vocab_size, (1, 1), device=device)
58
+ out2, _ = model(x, past_key_values=past_kv, use_cache=True)
59
+ past_kv = out2.past_key_values
60
+ elapsed = time.monotonic() - t0
61
+ return max_new_tokens / elapsed
62
+
63
+
64
+ def kv_cache_memory_bytes(past_key_values, model):
65
+ """Estimate total KV cache memory in bytes (fp32)."""
66
+ total = 0
67
+ for i, kv in enumerate(past_key_values):
68
+ if kv is None:
69
+ continue
70
+ for t in kv:
71
+ total += t.numel() * t.element_size()
72
+ return total
73
+
74
+
75
+ def run_benchmark(args):
76
+ config = ChronosConfig(
77
+ hidden_size=args.hidden_size,
78
+ num_hidden_layers=args.num_hidden_layers,
79
+ num_experts=args.num_experts,
80
+ use_moe=True,
81
+ use_hybrid_attention=True,
82
+ kv_latent_dim=args.kv_latent_dim,
83
+ sliding_window_size=args.sliding_window_size,
84
+ vram_budget_gb=args.vram_budget_gb,
85
+ )
86
+ model = ChronosForCausalLM(config).to(args.device)
87
+
88
+ if args.model_path:
89
+ weights = torch.load(args.model_path, map_location=args.device)
90
+ model.load_state_dict(weights, strict=False)
91
+ print(f"Loaded: {args.model_path}")
92
+
93
+ tokenizer = AutoTokenizer.from_pretrained(
94
+ chronos.deps.get_tokenizer_path()
95
+ )
96
+
97
+ from dataset.lm_dataset import PretrainDataset
98
+ dataset = PretrainDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
99
+ loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False)
100
+
101
+ print("\n=== Phase 3 Benchmark ===")
102
+
103
+ # 1. Perplexity
104
+ ppl = compute_perplexity(model, loader, args.device)
105
+ print(f"Perplexity: {ppl:.2f}")
106
+
107
+ # 2. Throughput
108
+ sample_ids = torch.randint(0, config.vocab_size, (1, 32))
109
+ tps = measure_throughput(model, sample_ids, args.device, args.max_new_tokens)
110
+ print(f"Throughput: {tps:.1f} tokens/s")
111
+
112
+ # 3. KV cache memory after N decode steps
113
+ model.eval()
114
+ with torch.no_grad():
115
+ x = torch.randint(0, config.vocab_size, (1, 32), device=args.device)
116
+ out, _ = model(x, use_cache=True)
117
+ past_kv = out.past_key_values
118
+ for _ in range(args.max_new_tokens):
119
+ xn = torch.randint(0, config.vocab_size, (1, 1), device=args.device)
120
+ out2, _ = model(xn, past_key_values=past_kv, use_cache=True)
121
+ past_kv = out2.past_key_values
122
+
123
+ kv_bytes = kv_cache_memory_bytes(past_kv, model)
124
+ print(f"KV cache after {args.max_new_tokens} steps: {kv_bytes/1024:.1f} KB")
125
+
126
+ # 4. Layer-wise cache sizes
127
+ print("\nLayer-wise KV cache:")
128
+ for i, kv in enumerate(past_kv):
129
+ if kv is not None:
130
+ layer_type = type(model.model.layers[i].self_attn).__name__
131
+ seq_len = kv[0].shape[1]
132
+ print(f" Layer {i:2d} ({layer_type:25s}): seq_len={seq_len:4d} "
133
+ f"bytes={sum(t.numel()*t.element_size() for t in kv)//1024}KB")
134
+
135
+ print(f"\n=== Summary ===")
136
+ print(f" PPL={ppl:.2f} TPS={tps:.1f} KV={kv_bytes//1024}KB "
137
+ f"hybrid_attn=True window={config.sliding_window_size}")
138
+
139
+
140
+ def main():
141
+ parser = argparse.ArgumentParser()
142
+ parser.add_argument("--model_path", type=str, default=None)
143
+ parser.add_argument("--data_path", type=str, required=True)
144
+ parser.add_argument("--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu")
145
+ parser.add_argument("--batch_size", type=int, default=4)
146
+ parser.add_argument("--max_seq_len", type=int, default=256)
147
+ parser.add_argument("--max_new_tokens", type=int, default=64)
148
+ parser.add_argument("--hidden_size", type=int, default=512)
149
+ parser.add_argument("--num_hidden_layers", type=int, default=8)
150
+ parser.add_argument("--num_experts", type=int, default=4)
151
+ parser.add_argument("--kv_latent_dim", type=int, default=64)
152
+ parser.add_argument("--sliding_window_size", type=int, default=2048)
153
+ parser.add_argument("--vram_budget_gb", type=float, default=4.0)
154
+ args = parser.parse_args()
155
+ run_benchmark(args)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()