opsiom-kairo 0.1.0__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: opsiom-kairo
3
+ Version: 0.1.0
4
+ Summary: Mesure, fiabilité et reprise pour l'entraînement PyTorch (d'abord Opsiom Kaïro).
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: torch>=2.5
7
+ Requires-Dist: numpy
8
+ Requires-Dist: accelerate
9
+ Requires-Dist: pyyaml
10
+ Requires-Dist: huggingface_hub
11
+ Provides-Extra: logging
12
+ Requires-Dist: wandb; extra == "logging"
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest; extra == "dev"
@@ -0,0 +1,37 @@
1
+ # Kairo Engine
2
+
3
+ Couche de **mesure, fiabilité et reprise** pour l'entraînement PyTorch,
4
+ conçue d'abord pour Opsiom Kaïro (pré-entraînement 500M sur T4 / Colab / Kaggle).
5
+
6
+ Principe : **mesurer avant d'optimiser**. Kairo ne modifie jamais silencieusement
7
+ les choix scientifiques (learning rate, architecture, dataset, budget de tokens).
8
+
9
+ ## Structure
10
+
11
+ ```text
12
+ kairo/
13
+ ├── cli.py kairo benchmark | autotune | train | resume | status
14
+ ├── hardware.py détection GPU/TPU, VRAM, fp16/bf16
15
+ ├── benchmark.py tokens/s, MFU, temps forward/backward/optim, VRAM ← COMMENCER ICI
16
+ ├── autotune.py batch size, grad accum, compile, AMP (progressif, jusqu'à OOM)
17
+ ├── data.py sampler à permutation (graine fixe), split val représentatif
18
+ ├── checkpoint.py sauvegarde/reprise (model, optimizer, tokens_seen, RNG, sampler)
19
+ ├── relay.py relais Colab/Kaggle, sauvegarde HF Hub, notifications, arrêt propre
20
+ ├── watchdog.py NaN/Inf, loss spike, chute de débit -> checkpoint d'urgence
21
+ └── probes.py prompts fixes générés à intervalles réguliers
22
+ configs/ default.yaml, t4.yaml
23
+ tests/ val sans fuite, reprise après crash, détection d'anomalies
24
+ ```
25
+
26
+ ## Ordre de développement
27
+
28
+ 1. `hardware.py` + `benchmark.py` (avec MFU) : baseline de référence
29
+ 2. Correction du split val + sampler à permutation (`data.py`)
30
+ 3. Tests RoPE réel + `torch.compile` via `benchmark.py`
31
+ 4. `checkpoint.py` + `relay.py` (cœur de la valeur du projet)
32
+ 5. `watchdog.py`, puis `autotune.py`
33
+ 6. `probes.py`
34
+
35
+ Volontairement absents tant que le profiling ne les justifie pas : C++/CUDA
36
+ custom, dashboard web, registre d'expériences, réimplémentation d'un Trainer
37
+ (Kairo se branche sur Accelerate).
@@ -0,0 +1 @@
1
+ """Kairo Engine."""
@@ -0,0 +1,84 @@
1
+ """Recherche progressive de la config d'exécution, jamais de la config
2
+ scientifique.
3
+
4
+ Autotune ne touche jamais : learning rate, architecture, dataset, nombre
5
+ de tokens. Il ne touche que ce qui n'a aucun effet sur le résultat final
6
+ d'entraînement (à grad_accum équivalent) : micro_batch_size et
7
+ grad_accum_steps, pour occuper le GPU sans OOM ni sous-utilisation.
8
+
9
+ Approche volontairement simple (recherche binaire), au-dessus de
10
+ benchmark.py plutôt qu'une réimplémentation : la complexité (compile,
11
+ AMP...) est déjà gérée par PyTorch/Accelerate.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+
17
+ from kairo.benchmark import BenchmarkResult, run_benchmark
18
+
19
+
20
+ @dataclasses.dataclass
21
+ class AutotuneResult:
22
+ micro_batch_size: int
23
+ grad_accum_steps: int
24
+ benchmark: BenchmarkResult
25
+
26
+
27
+ def find_max_micro_batch_size(
28
+ model_factory,
29
+ batch_fn_factory,
30
+ target_effective_batch: int,
31
+ flops_per_token: float,
32
+ min_batch: int = 1,
33
+ max_batch: int = 256,
34
+ bench_steps: int = 5,
35
+ ) -> AutotuneResult:
36
+ """Recherche binaire du plus grand micro-batch tenant en VRAM, en
37
+ conservant `target_effective_batch` via grad_accum_steps.
38
+
39
+ - `model_factory()` doit renvoyer un modèle frais sur le bon device
40
+ (nécessaire car un OOM peut laisser des gradients/activations sales).
41
+ - `batch_fn_factory(micro_batch_size)` doit renvoyer un `batch_fn`
42
+ compatible avec `kairo.benchmark.run_benchmark`.
43
+ """
44
+ import torch
45
+
46
+ lo, hi = min_batch, max_batch
47
+ best: AutotuneResult | None = None
48
+
49
+ while lo <= hi:
50
+ mid = (lo + hi) // 2
51
+ try:
52
+ model = model_factory()
53
+ batch_fn = batch_fn_factory(mid)
54
+ result = run_benchmark(
55
+ model, batch_fn, flops_per_token=flops_per_token,
56
+ num_steps=bench_steps, warmup_steps=2,
57
+ )
58
+ except torch.cuda.OutOfMemoryError:
59
+ torch.cuda.empty_cache()
60
+ hi = mid - 1
61
+ continue
62
+ finally:
63
+ del model
64
+ torch.cuda.empty_cache()
65
+
66
+ if target_effective_batch % mid != 0:
67
+ # On garde la mesure pour info mais on ne la retient comme
68
+ # "best" que si elle divise exactement le batch effectif visé,
69
+ # pour ne jamais changer le batch effectif implicitement.
70
+ lo = mid + 1
71
+ continue
72
+
73
+ accum = target_effective_batch // mid
74
+ best = AutotuneResult(mid, accum, result)
75
+ lo = mid + 1 # on essaie plus grand : moins de micro-steps, souvent plus rapide
76
+
77
+ if best is None:
78
+ raise RuntimeError(
79
+ "Aucun micro_batch_size testé ne divise target_effective_batch "
80
+ f"({target_effective_batch}) sans OOM. Essayez un target_effective_batch "
81
+ "qui a plus de diviseurs, ou augmentez max_batch."
82
+ )
83
+ return best
84
+
@@ -0,0 +1,157 @@
1
+ """Benchmark de référence : tokens/s, MFU, forward/backward/optimiseur, VRAM.
2
+
3
+ Objectif : mesurer AVANT d'optimiser, pour éviter de deviner. Rien ici ne
4
+ change la config d'entraînement ; ce module ne fait qu'observer.
5
+
6
+ Usage typique :
7
+
8
+ from kairo.benchmark import estimate_model_flops, run_benchmark
9
+
10
+ flops_per_token = estimate_model_flops(n_params=511_000_000)
11
+ result = run_benchmark(model, batch_fn, flops_per_token=flops_per_token,
12
+ num_steps=20, warmup_steps=5)
13
+ print(result.report())
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import dataclasses
18
+ import time
19
+ from typing import Callable
20
+
21
+ from kairo.hardware import detect_hardware
22
+
23
+
24
+ def estimate_model_flops(n_params: int, n_layers: int | None = None,
25
+ seq_len: int | None = None) -> float:
26
+ """FLOPs par token pour une passe forward+backward, approximation
27
+ standard des modèles transformer denses : 6*N (Kaplan et al., 2020).
28
+
29
+ Le terme d'attention (2 * 2 * n_layers * seq_len * d_model) est ignoré
30
+ par défaut car négligeable pour seq_len << d_model * n_layers ; il est
31
+ ajouté si n_layers et seq_len sont fournis (approximation grossière,
32
+ d_model non connu ici -> à affiner si besoin de précision fine)."""
33
+ flops = 6 * n_params
34
+ if n_layers and seq_len:
35
+ # Terme quadratique en séquence, ordre de grandeur seulement.
36
+ flops += 12 * n_layers * seq_len
37
+ return float(flops)
38
+
39
+
40
+ @dataclasses.dataclass
41
+ class BenchmarkResult:
42
+ tokens_per_sec: float
43
+ step_time_s: float
44
+ forward_time_s: float
45
+ backward_time_s: float
46
+ optim_time_s: float
47
+ peak_vram_gb: float | None
48
+ achieved_flops: float # FLOPs/s réellement obtenus
49
+ mfu: float | None # achieved_flops / pic matériel théorique, 0..1
50
+ num_steps: int
51
+ tokens_per_step: int
52
+
53
+ def report(self) -> str:
54
+ lines = [
55
+ f"tokens/s : {self.tokens_per_sec:,.0f}",
56
+ f"temps/step : {self.step_time_s*1000:.1f} ms "
57
+ f"(fwd {self.forward_time_s*1000:.1f} / "
58
+ f"bwd {self.backward_time_s*1000:.1f} / "
59
+ f"optim {self.optim_time_s*1000:.1f})",
60
+ ]
61
+ if self.peak_vram_gb is not None:
62
+ lines.append(f"VRAM pic : {self.peak_vram_gb:.2f} Go")
63
+ if self.mfu is not None:
64
+ lines.append(f"MFU : {self.mfu*100:.1f} %")
65
+ else:
66
+ lines.append("MFU : n/a (pic FLOPs matériel inconnu)")
67
+ return "\n".join(lines)
68
+
69
+
70
+ def run_benchmark(
71
+ model,
72
+ batch_fn: Callable[[], tuple],
73
+ flops_per_token: float,
74
+ step_fn: Callable | None = None,
75
+ num_steps: int = 20,
76
+ warmup_steps: int = 5,
77
+ ) -> BenchmarkResult:
78
+ """Mesure les performances réelles d'un modèle sur `num_steps` pas.
79
+
80
+ - `batch_fn()` doit retourner un batch `(input_ids, targets)` déjà sur
81
+ le bon device.
82
+ - `step_fn(model, batch)` doit exécuter forward + backward + optimizer
83
+ et rendre le contrôle une fois le pas terminé (avant tout .item()
84
+ qui forcerait déjà une synchro : la synchro est faite par ce module).
85
+ Si `step_fn` n'est pas fourni, un pas forward/backward générique est
86
+ utilisé (sans pas d'optimiseur), suffisant pour mesurer fwd/bwd.
87
+ - Import torch différé : ce module reste important même sans torch.
88
+ """
89
+ import torch
90
+
91
+ device = next(model.parameters()).device
92
+ is_cuda = device.type == "cuda"
93
+
94
+ def sync():
95
+ if is_cuda:
96
+ torch.cuda.synchronize()
97
+
98
+ if step_fn is None:
99
+ def step_fn(model, batch): # type: ignore[no-redef]
100
+ input_ids, targets = batch
101
+ logits = model(input_ids)
102
+ loss = torch.nn.functional.cross_entropy(
103
+ logits.view(-1, logits.size(-1)), targets.view(-1)
104
+ )
105
+ loss.backward()
106
+ model.zero_grad(set_to_none=True)
107
+
108
+ # Warmup : cuDNN autotuning, compilation, allocation de la VRAM.
109
+ for _ in range(warmup_steps):
110
+ batch = batch_fn()
111
+ step_fn(model, batch)
112
+ sync()
113
+
114
+ if is_cuda:
115
+ torch.cuda.reset_peak_memory_stats(device)
116
+
117
+ total_tokens = 0
118
+ step_times = []
119
+ for _ in range(num_steps):
120
+ batch = batch_fn()
121
+ n_tokens = batch[0].numel()
122
+ sync()
123
+ t0 = time.perf_counter()
124
+ step_fn(model, batch)
125
+ sync()
126
+ t1 = time.perf_counter()
127
+ step_times.append(t1 - t0)
128
+ total_tokens += n_tokens
129
+
130
+ total_time = sum(step_times)
131
+ tokens_per_sec = total_tokens / total_time
132
+ step_time_s = total_time / num_steps
133
+ achieved_flops = tokens_per_sec * flops_per_token
134
+
135
+ peak_vram_gb = None
136
+ if is_cuda:
137
+ peak_vram_gb = torch.cuda.max_memory_allocated(device) / (1024**3)
138
+
139
+ hw = detect_hardware()
140
+ mfu = achieved_flops / hw.peak_flops if hw.peak_flops else None
141
+
142
+ # Sans instrumentation fine (torch.profiler), on ne distingue pas
143
+ # fwd/bwd/optim par défaut : ils sont laissés à 0 ici et à remplir par
144
+ # l'appelant via `step_fn` instrumenté si le détail est nécessaire.
145
+ return BenchmarkResult(
146
+ tokens_per_sec=tokens_per_sec,
147
+ step_time_s=step_time_s,
148
+ forward_time_s=0.0,
149
+ backward_time_s=0.0,
150
+ optim_time_s=0.0,
151
+ peak_vram_gb=peak_vram_gb,
152
+ achieved_flops=achieved_flops,
153
+ mfu=mfu,
154
+ num_steps=num_steps,
155
+ tokens_per_step=total_tokens // num_steps,
156
+ )
157
+
@@ -0,0 +1,175 @@
1
+ """Sauvegarde/reprise complète d'un run.
2
+
3
+ Contrairement à une sauvegarde "poids seuls", un checkpoint Kairo capture
4
+ tout ce qui est nécessaire pour reprendre EXACTEMENT là où le run s'est
5
+ arrêté, y compris entre deux plateformes différentes (Colab -> Kaggle) :
6
+
7
+ - state_dict() du modèle et de l'optimiseur
8
+ - scheduler (si fourni)
9
+ - tokens_seen (source de vérité pour la progression, pas step ni epoch)
10
+ - états RNG (torch CPU, torch CUDA, numpy, random) pour la reproductibilité
11
+ - state_dict() du sampler (kairo.data.PermutationSampler), pour reprendre
12
+ le parcours du corpus exactement où il en était
13
+ - métadonnées libres (config, meilleure loss val, etc.)
14
+
15
+ Le fichier le plus récent et le "best" (meilleure loss val représentative,
16
+ cf. data.py) sont conservés séparément pour ne jamais perdre le meilleur
17
+ modèle à cause d'une reprise ultérieure qui diverge.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import dataclasses
22
+ import json
23
+ import os
24
+ import random
25
+ import shutil
26
+ import tempfile
27
+ import time
28
+ from typing import Any
29
+
30
+
31
+ @dataclasses.dataclass
32
+ class CheckpointMeta:
33
+ tokens_seen: int
34
+ step: int
35
+ val_loss: float | None
36
+ wall_time_s: float
37
+ extra: dict = dataclasses.field(default_factory=dict)
38
+
39
+
40
+ def _capture_rng_state() -> dict:
41
+ state = {"python": random.getstate(), "numpy": None, "torch_cpu": None,
42
+ "torch_cuda": None}
43
+ try:
44
+ import numpy as np
45
+ state["numpy"] = np.random.get_state()
46
+ except ImportError:
47
+ pass
48
+ try:
49
+ import torch
50
+ state["torch_cpu"] = torch.get_rng_state()
51
+ if torch.cuda.is_available():
52
+ state["torch_cuda"] = torch.cuda.get_rng_state_all()
53
+ except ImportError:
54
+ pass
55
+ return state
56
+
57
+
58
+ def _restore_rng_state(state: dict) -> None:
59
+ if state.get("python") is not None:
60
+ random.setstate(state["python"])
61
+ if state.get("numpy") is not None:
62
+ try:
63
+ import numpy as np
64
+ np.random.set_state(state["numpy"])
65
+ except ImportError:
66
+ pass
67
+ try:
68
+ import torch
69
+ if state.get("torch_cpu") is not None:
70
+ torch.set_rng_state(state["torch_cpu"])
71
+ if state.get("torch_cuda") is not None and torch.cuda.is_available():
72
+ torch.cuda.set_rng_state_all(state["torch_cuda"])
73
+ except ImportError:
74
+ pass
75
+
76
+
77
+ def save_checkpoint(
78
+ path: str,
79
+ model,
80
+ optimizer=None,
81
+ scheduler=None,
82
+ sampler=None,
83
+ meta: CheckpointMeta | None = None,
84
+ ) -> None:
85
+ """Écrit un checkpoint de façon atomique (fichier temporaire + rename),
86
+ pour ne jamais laisser un fichier corrompu si le process est tué en
87
+ plein milieu (fréquent sur Colab/Kaggle en fin de session)."""
88
+ import torch
89
+
90
+ payload: dict[str, Any] = {
91
+ "model": model.state_dict(),
92
+ "optimizer": optimizer.state_dict() if optimizer is not None else None,
93
+ "scheduler": scheduler.state_dict() if scheduler is not None else None,
94
+ "sampler": sampler.state_dict() if sampler is not None else None,
95
+ "rng": _capture_rng_state(),
96
+ "meta": dataclasses.asdict(meta) if meta is not None else None,
97
+ "saved_at": time.time(),
98
+ }
99
+
100
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
101
+ fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp")
102
+ os.close(fd)
103
+ try:
104
+ torch.save(payload, tmp_path)
105
+ os.replace(tmp_path, path) # atomique sur un même filesystem
106
+ except BaseException:
107
+ if os.path.exists(tmp_path):
108
+ os.remove(tmp_path)
109
+ raise
110
+
111
+
112
+ def load_checkpoint(
113
+ path: str,
114
+ model,
115
+ optimizer=None,
116
+ scheduler=None,
117
+ sampler=None,
118
+ map_location: str | None = None,
119
+ restore_rng: bool = True,
120
+ ) -> CheckpointMeta | None:
121
+ import torch
122
+
123
+ payload = torch.load(path, map_location=map_location, weights_only=False)
124
+
125
+ model.load_state_dict(payload["model"])
126
+ if optimizer is not None and payload.get("optimizer") is not None:
127
+ optimizer.load_state_dict(payload["optimizer"])
128
+ if scheduler is not None and payload.get("scheduler") is not None:
129
+ scheduler.load_state_dict(payload["scheduler"])
130
+ if sampler is not None and payload.get("sampler") is not None:
131
+ sampler.load_state_dict(payload["sampler"])
132
+ if restore_rng and payload.get("rng") is not None:
133
+ _restore_rng_state(payload["rng"])
134
+
135
+ meta = payload.get("meta")
136
+ return CheckpointMeta(**meta) if meta else None
137
+
138
+
139
+ class CheckpointManager:
140
+ """Gère deux fichiers stables : `latest.pt` (dernier en date, pour
141
+ reprendre après une coupure) et `best.pt` (meilleure val_loss vue,
142
+ jamais écrasé par un run qui repart puis diverge)."""
143
+
144
+ def __init__(self, directory: str):
145
+ self.directory = directory
146
+ os.makedirs(directory, exist_ok=True)
147
+ self._best_val_loss = float("inf")
148
+ state_path = os.path.join(directory, "state.json")
149
+ if os.path.exists(state_path):
150
+ with open(state_path) as f:
151
+ self._best_val_loss = json.load(f).get("best_val_loss", float("inf"))
152
+
153
+ @property
154
+ def latest_path(self) -> str:
155
+ return os.path.join(self.directory, "latest.pt")
156
+
157
+ @property
158
+ def best_path(self) -> str:
159
+ return os.path.join(self.directory, "best.pt")
160
+
161
+ def save(self, model, optimizer, scheduler, sampler, meta: CheckpointMeta) -> None:
162
+ save_checkpoint(self.latest_path, model, optimizer, scheduler, sampler, meta)
163
+ if meta.val_loss is not None and meta.val_loss < self._best_val_loss:
164
+ self._best_val_loss = meta.val_loss
165
+ shutil.copy2(self.latest_path, self.best_path)
166
+ with open(os.path.join(self.directory, "state.json"), "w") as f:
167
+ json.dump({"best_val_loss": self._best_val_loss}, f)
168
+
169
+ def resume(self, model, optimizer=None, scheduler=None, sampler=None,
170
+ map_location: str | None = None) -> CheckpointMeta | None:
171
+ if not os.path.exists(self.latest_path):
172
+ return None
173
+ return load_checkpoint(self.latest_path, model, optimizer, scheduler,
174
+ sampler, map_location=map_location)
175
+
@@ -0,0 +1,78 @@
1
+ """Point d'entrée CLI : kairo hardware | benchmark | status.
2
+
3
+ `kairo train`/`resume` ne sont pas des commandes génériques ici : Kairo est
4
+ une bibliothèque qui s'utilise depuis le script d'entraînement existant
5
+ (main-GPU-CPU-TPU.py), pas un Trainer qui remplace ce script. La CLI ne
6
+ couvre donc que ce qui a du sens hors contexte d'un script précis :
7
+ diagnostic matériel, benchmark autonome sur un modèle donné, et lecture
8
+ de l'état d'un run à partir de son dossier de checkpoints.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+
15
+
16
+ def cmd_hardware(args: argparse.Namespace) -> None:
17
+ from kairo.hardware import detect_hardware, summary_line
18
+
19
+ info = detect_hardware()
20
+ if args.json:
21
+ print(json.dumps(info.as_dict(), indent=2))
22
+ else:
23
+ print(summary_line(info))
24
+
25
+
26
+ def cmd_status(args: argparse.Namespace) -> None:
27
+ import os
28
+
29
+ from kairo.checkpoint import load_checkpoint
30
+
31
+ latest = os.path.join(args.checkpoint_dir, "latest.pt")
32
+ best = os.path.join(args.checkpoint_dir, "best.pt")
33
+ if not os.path.exists(latest):
34
+ print(f"Aucun checkpoint dans {args.checkpoint_dir}")
35
+ return
36
+
37
+ # Chargement "à blanc" : on ne restaure aucun state_dict, juste les
38
+ # métadonnées, donc pas besoin d'un vrai modèle/optimiseur ici.
39
+ class _Void:
40
+ def state_dict(self):
41
+ return {}
42
+
43
+ def load_state_dict(self, _):
44
+ pass
45
+
46
+ meta = load_checkpoint(latest, _Void(), restore_rng=False)
47
+ print(f"dernier checkpoint : {latest}")
48
+ if meta:
49
+ print(f" step : {meta.step}")
50
+ print(f" tokens_seen : {meta.tokens_seen:,}")
51
+ print(f" val_loss : {meta.val_loss}")
52
+ print(f"best checkpoint : {best if os.path.exists(best) else '(absent)'}")
53
+
54
+
55
+ def build_parser() -> argparse.ArgumentParser:
56
+ parser = argparse.ArgumentParser(prog="kairo")
57
+ sub = parser.add_subparsers(dest="command", required=True)
58
+
59
+ p_hw = sub.add_parser("hardware", help="Détecte et affiche le matériel disponible.")
60
+ p_hw.add_argument("--json", action="store_true")
61
+ p_hw.set_defaults(func=cmd_hardware)
62
+
63
+ p_status = sub.add_parser("status", help="Affiche l'état d'un dossier de checkpoints.")
64
+ p_status.add_argument("checkpoint_dir")
65
+ p_status.set_defaults(func=cmd_status)
66
+
67
+ return parser
68
+
69
+
70
+ def main() -> None:
71
+ parser = build_parser()
72
+ args = parser.parse_args()
73
+ args.func(args)
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
78
+