Shamrock 2025.10.0
Astrophysical Code
Loading...
Searching...
No Matches
SimulationRunner.py
1import types
2from collections.abc import Callable
3from dataclasses import dataclass
4from math import inf
5
6import shamrock
7from shamrock.utils.dump import ShamrockDumpHandleHelper
8
9# ----------------------------
10# decorators
11# ----------------------------
12
13
14def callback(
15 *,
16 tsim_interval=None,
17 iter_count_interval=None,
18 walltime_interval=None,
19 at_tsim=None,
20):
21 """
22 Decorator to mark a function as a simulation callback.
23
24 Example:
25 @callback(tsim_interval=1.0)
26 def analysis(self, icallback):
27 print("analysis", icallback)
28
29 @callback(at_tsim=[1.0, 5.0, 10.0])
30 def snapshot(self, icallback):
31 print("snapshot", icallback)
32
33 Args:
34 tsim_interval: The time step of the callback.
35 iter_count_interval: The iteration count interval of the callback.
36 walltime_interval: The walltime interval of the callback.
37 at_tsim: Exact simulation time(s) at which to trigger the callback (float or list of floats).
38 Returns:
39 The decorated function.
40 """
41
42 if (
43 tsim_interval is None
44 and iter_count_interval is None
45 and walltime_interval is None
46 and at_tsim is None
47 ):
48 raise ValueError("At least one of the intervals must be provided")
49
50 if at_tsim is not None:
51 if isinstance(at_tsim, (int, float)):
52 at_tsim = [float(at_tsim)]
53 else:
54 at_tsim = sorted(float(t) for t in at_tsim)
55
56 def deco(func):
57 func.__simulation_callback__ = {
58 "func_name": func.__name__,
59 "tsim_interval": tsim_interval,
60 "iter_count_interval": iter_count_interval,
61 "walltime_interval": walltime_interval,
62 "at_tsim": at_tsim,
63 }
64
65 return func
66
67 return deco
68
69
70def simulation_setup(func):
71 """
72 Decorator to mark a function as a simulation setup.
73
74 Example:
75 @simulation_setup
76 def setup(self):
77 print("setup")
78 """
79
80 func.__simulation_setup__ = True
81 return func
82
83
84# ----------------------------
85# metaclass
86# ----------------------------
87
88
89class SimulationMeta(type):
90 def __new__(mcls, name, bases, namespace):
91
92 cls = super().__new__(mcls, name, bases, namespace)
93
94 # ----------------------------
95 # verbosity flag (default False)
96 # Just add __debug_class_creation__ = True to a derived class to enable verbose mode
97 # ----------------------------
98 verbose = namespace.get("__debug_class_creation__", False)
99
100 if verbose:
101 print("\n==============================")
102 print(f"[metaclass] Creating class: {name}")
103 print("==============================\n")
104
105 print("=== RAW NAMESPACE ===")
106 for k, v in namespace.items():
107 print(f"{k:25} {type(v)}")
108 print()
109
110 # skip base class
111 if name == "SimulationRunner":
112 return cls
113
114 callbacks = []
115 setup_func = None
116
117 if verbose:
118 print("=== INSPECTION ===")
119
120 for name, obj in namespace.items():
121 if isinstance(obj, (types.FunctionType, classmethod, staticmethod)):
122 if isinstance(obj, (classmethod, staticmethod)):
123 func = obj.__func__
124 else:
125 func = obj
126
127 cb = getattr(func, "__simulation_callback__", None)
128 setup = getattr(func, "__simulation_setup__", None)
129
130 if verbose:
131 if cb is not None:
132 print(f"[decorator callback] applying to: {name} | value: {cb}")
133 if setup is not None:
134 print(f"[decorator setup] applying to: {name} | value: {setup}")
135
136 if cb is not None:
137 callbacks.append((name, cb))
138
139 if setup:
140 if setup_func is not None:
141 raise ValueError("Multiple setup functions")
142
143 setup_func = (name, func)
144
145 if verbose:
146 print("\n=== Metaclass result ===")
147 print("callbacks:", callbacks)
148 print("setup_func:", setup_func)
149
150 if setup_func is None:
151 raise ValueError("No simulation setup function found")
152
153 cls._declared_callbacks = callbacks
154 cls._setup = setup_func
155
156 return cls
157
158
159# ----------------------------
160# base class
161# ----------------------------
162def rank_0_print(*args, **kwargs):
163 if shamrock.sys.world_rank() == 0:
164 print(*args, **kwargs)
165
166
167@dataclass
169 func: Callable
170 name: str
171
172 tsim_interval: float | None = None
173 iter_count_interval: int | None = None
174 walltime_interval: float | None = None
175 at_tsim: list[float] | None = None
176
177
179 def __init__(self, info: CallbackInfo, tsim_start: float):
180 self.info = info
181 self.counter = 0
182
183 candidates = []
184 if info.tsim_interval is not None:
185 candidates.append(tsim_start)
186 if info.at_tsim is not None:
187 future = [t for t in info.at_tsim if t >= tsim_start]
188 if future:
189 candidates.append(min(future))
190 self.next_tsim = min(candidates) if candidates else None
191
192 self.next_iter_count = 0 if info.iter_count_interval is not None else None
193 self.next_walltime = 0.0 if info.walltime_interval is not None else None
194
195 def advance(self, t_model: float, iter_count: int, walltime: float):
196 self.counter += 1
197
198 candidates = []
199 if self.info.tsim_interval is not None:
200 candidates.append(t_model + self.info.tsim_interval)
201 if self.info.at_tsim is not None:
202 future = [t for t in self.info.at_tsim if t > t_model]
203 if future:
204 candidates.append(min(future))
205 self.next_tsim = min(candidates) if candidates else None
206
207 if self.info.iter_count_interval is not None:
208 self.next_iter_count = iter_count + self.info.iter_count_interval
209 if self.info.walltime_interval is not None:
210 self.next_walltime = walltime + self.info.walltime_interval
211
212 rank_0_print(f'[Simulation] Advancing callback "{self.info.name}"')
213 if self.next_tsim is not None:
214 rank_0_print(f" -> t = {t_model} -> {self.next_tsim}")
215 if self.info.iter_count_interval is not None:
216 rank_0_print(f" -> iter = {iter_count} -> {self.next_iter_count}")
217 if self.info.walltime_interval is not None:
218 rank_0_print(f" -> walltime = {walltime} -> {self.next_walltime}")
219
220 def should_trigger(self, t_model: float, iter_count: int, walltime: float) -> bool:
221 trig = False
222
223 log = []
224
225 if self.next_tsim is not None:
226 if t_model >= self.next_tsim: # should i add a tolerance here ?
227 trig = True
228 log.append(f" -> t = {t_model} >= {self.next_tsim}")
229 if self.info.iter_count_interval is not None:
230 if iter_count >= self.next_iter_count:
231 trig = True
232 log.append(f" -> iter = {iter_count} >= {self.next_iter_count}")
233 if self.info.walltime_interval is not None:
234 if walltime >= self.next_walltime:
235 trig = True
236 log.append(f" -> walltime = {walltime} >= {self.next_walltime}")
237
238 if trig:
239 rank_0_print(
240 f'[Simulation] Triggering callback "{self.info.name}" (counter = {self.counter}):\n'
241 + "\n".join(log)
242 )
243
244 return trig
245
246 def to_dict(self):
247 return {
248 "counter": self.counter,
249 "next_tsim": self.next_tsim,
250 "next_iter_count": self.next_iter_count,
251 "next_walltime": self.next_walltime,
252 }
253
254 def from_dict(self, data: dict):
255 self.counter = data["counter"]
256 self.next_tsim = data["next_tsim"]
257 self.next_iter_count = data["next_iter_count"]
258 self.next_walltime = data["next_walltime"]
259
260
261class SimulationRunner(metaclass=SimulationMeta):
262 """
263 SimulationRunner is a base class to declare a simulation with setup & callbacks.
264
265 A derived class must define:
266 - t_end: float = <end time of the simulation>
267 - a setup (any function decorated with @simulation_setup)
268
269 And can define callbacks (any function decorated with @callback):
270
271 < call every tsim = i * time_step >
272 - @callback(tsim_interval=1.0)
273 def analysis(self, icallback):
274 rank_0_print("analysis")
275
276 < call at exact simulation times >
277 - @callback(at_tsim=[1.0, 5.0, 10.0])
278 def snapshot(self, icallback):
279 rank_0_print("snapshot")
280
281 < call when tsim = dt_stop, niter_max is reached or walltime_step is reached >
282 - @callback(tsim_interval=dt_stop, iter_count_interval=1000, walltime_interval=30*60)
283 def do_checkpoint(self, icheckpoint):
284 self.dump_helper.dump(icheckpoint)
285
286 Note that for the last one that this reset the counters until next callback.
287 The trigger conditions are inclusive and reset the counters for all triggers of that callback.
288 """
289
290 t_end: float | None = None
291 dump_prefix: str | None = None
292
293 cur_t: float = 0.0
294 cur_iter_count: int = 0
295
296 _declared_callbacks: list # Will be filled by the metaclass
297 _setup: tuple[str, Callable] # Will be filled by the metaclass
298
299 def __init__(self, model):
300 self.model = model
301
302 self._callbacks = []
303
304 for name, info in self._declared_callbacks:
305 copied = CallbackInfo(
306 func=getattr(self, name),
307 name=name,
308 tsim_interval=info["tsim_interval"],
309 iter_count_interval=info["iter_count_interval"],
310 walltime_interval=info["walltime_interval"],
311 at_tsim=info["at_tsim"],
312 )
313
314 self._callbacks.append(copied)
315
316 self._callbacks_state = None
317
318 if self.dump_prefix is not None:
319 self.dump_helper = ShamrockDumpHandleHelper(self.model, self.dump_prefix, metadata=True)
320 else:
321 self.dump_helper = None
322
323 if self.t_end is None:
324 raise ValueError(f"{type(self).__name__}.t_end must be defined")
325
326 if self._declared_callbacks is None:
327 raise ValueError(f"{type(self).__name__}._declared_callbacks must be defined")
328
329 if self._setup is None:
330 raise ValueError(f"{type(self).__name__}._setup must be defined")
331
332 def do_checkpoint(self, icheckpoint: int, **kwargs):
333
334 if self.dump_prefix is None:
335 raise ValueError(f"{type(self).__name__}.dump_prefix must be defined")
336
337 metadata = {
338 "cur_t": self.cur_t,
339 "cur_iter_count": self.cur_iter_count,
340 }
341
342 for ic, c in enumerate(self._callbacks):
343 metadata[c.name] = self._callbacks_state[ic].to_dict()
344
345 rank_0_print("[Simulation] Doing checkpoint")
346 self.dump_helper.write_dump(icheckpoint, metadata=metadata, **kwargs)
347 rank_0_print("[Simulation] Checkpoint done")
348
349 def run_setup(self):
350
351 name, func = self._setup
352 rank_0_print()
353 rank_0_print(f"[Simulation] Running setup function: {name}")
354 rank_0_print()
355 func(self)
356
357 self.cur_t = self.model.get_time()
358 self.cur_iter_count = 0
359
360 rank_0_print()
361 rank_0_print("[Simulation] Setting up callbacks states")
362 self._callbacks_state = [CallbackState(c, self.cur_t) for c in self._callbacks]
363
364 rank_0_print("[Simulation] Setup done")
365
366 def restore_from_checkpoint(self, metadata: dict):
367 self.cur_t = metadata["cur_t"]
368 self.cur_iter_count = metadata["cur_iter_count"]
369
370 rank_0_print("[Simulation] Setting up callbacks states")
371 self._callbacks_state = [CallbackState(c, self.cur_t) for c in self._callbacks]
372 rank_0_print("[Simulation] Restoring callbacks states")
373
374 wtime = shamrock.get_wtime_sync()
375
376 for ic, c in enumerate(self._callbacks):
377 self._callbacks_state[ic].from_dict(metadata[c.name])
378
379 # Correct the walltime to be the current walltime
380 # If not done it will be the next_walltime relative to when the dump was done
381 for ic, c in enumerate(self._callbacks):
382 if c.walltime_interval is not None:
383 self._callbacks_state[ic].next_walltime = wtime + c.walltime_interval
384
385 # in case we checkpoint in the middle of the callback sequence
387
388 def evolve_until(
389 self, next_time: float, next_iter_count: int | None, next_walltime: float | None
390 ):
391
392 if next_time < self.cur_t:
393 raise ValueError(f"Next callback time {next_time} is in the past")
394
395 if next_iter_count is not None:
396 if next_iter_count < self.cur_iter_count:
397 raise ValueError(f"Next callback iter count {next_iter_count} is in the past")
398
399 if next_iter_count is None:
400 next_iter_count = -1
401 else:
402 next_iter_count = next_iter_count - self.cur_iter_count
403
404 if next_walltime is None:
405 next_walltime = -1.0
406
407 result = self.model.evolve_until(
408 next_time, niter_max=next_iter_count, max_walltime=next_walltime
409 )
410 self.cur_t = self.model.get_time()
411 self.cur_iter_count += result.iter_count
412
413 def trigger_and_advance_callbacks(self):
414 callback_to_advance = []
415
416 wtime = shamrock.get_wtime_sync()
417 for ic, c in enumerate(self._callbacks):
418 trig = self._callbacks_state[ic].should_trigger(self.cur_t, self.cur_iter_count, wtime)
419 if trig:
420 counter = self._callbacks_state[ic].counter
421 rank_0_print("--------------------------------")
422 c.func(counter)
423 rank_0_print("--------------------------------")
424 callback_to_advance.append(ic)
425
426 # in case there is a long running callback this won't fuck up the walltimes
427 # Also if a callback checkpoints we won't be in a partially advanced state
428
429 for ic in callback_to_advance:
430 self._callbacks_state[ic].advance(self.cur_t, self.cur_iter_count, wtime)
431
432 def goto_run_next_callback(self):
433
434 next_time = self.t_end
435 next_iter_count = None
436 next_walltime = None
437
438 for ic, _ in enumerate(self._callbacks):
439 state = self._callbacks_state[ic]
440
441 if state.next_tsim is not None:
442 next_time = min(next_time, state.next_tsim)
443
444 if state.next_iter_count is not None:
445 if next_iter_count is None:
446 next_iter_count = state.next_iter_count
447 else:
448 next_iter_count = min(next_iter_count, state.next_iter_count)
449
450 if state.next_walltime is not None:
451 if next_walltime is None:
452 next_walltime = state.next_walltime
453 else:
454 next_walltime = min(next_walltime, state.next_walltime)
455
456 rank_0_print()
457 rank_0_print(
458 f"[Simulation] Evolve until next trigger(s) :\n"
459 f" -> t = {next_time} (current = {self.cur_t})\n"
460 f" -> iter = {next_iter_count} (current = {self.cur_iter_count})\n"
461 f" -> walltime = {next_walltime} (current = {shamrock.get_wtime_sync()})"
462 )
463 rank_0_print()
464
465 self.evolve_until(next_time, next_iter_count, next_walltime)
466
468
469 def run(self):
470
471 if self.dump_helper is not None:
472 metadata = self.dump_helper.load_last_dump_or(self.run_setup)
473 if metadata is not None:
474 rank_0_print("[Simulation] Restoring Simulation handle from checkpoint")
475 self.restore_from_checkpoint(metadata)
476 else:
477 self.run_setup()
478
479 while self.cur_t < self.t_end:
evolve_until(self, float next_time, int|None next_iter_count, float|None next_walltime)