|
| 1 | +# SPDX-License-Identifier: Apache-2.0 |
| 2 | + |
| 3 | +""" |
| 4 | +Callback system for training lifecycle hooks. |
| 5 | +
|
| 6 | +Provides async, fire-and-forget callbacks that observe training events |
| 7 | +without blocking the training loop or propagating exceptions. |
| 8 | +""" |
| 9 | + |
| 10 | +# Standard |
| 11 | +from dataclasses import dataclass, field |
| 12 | +from typing import Any |
| 13 | +import asyncio |
| 14 | +import base64 |
| 15 | +import copy |
| 16 | +import dataclasses |
| 17 | +import inspect |
| 18 | +import json |
| 19 | +import logging |
| 20 | +import textwrap |
| 21 | +import threading |
| 22 | + |
| 23 | +logger = logging.getLogger("instructlab.training") |
| 24 | + |
| 25 | +HOOK_NAMES = [ |
| 26 | + "on_train_begin", |
| 27 | + "on_epoch_begin", |
| 28 | + "on_step_begin", |
| 29 | + "on_before_forward", |
| 30 | + "on_after_backward", |
| 31 | + "on_pre_optimizer_step", |
| 32 | + "on_optimizer_step", |
| 33 | + "on_log", |
| 34 | + "on_evaluate", |
| 35 | + "on_save", |
| 36 | + "on_step_end", |
| 37 | + "on_epoch_end", |
| 38 | + "on_train_end", |
| 39 | +] |
| 40 | + |
| 41 | + |
| 42 | +@dataclass |
| 43 | +class TrainingContext: |
| 44 | + """Mutable training state maintained by the training loop. |
| 45 | +
|
| 46 | + The CallbackManager snapshots this before dispatching to callbacks, |
| 47 | + so callback authors receive an effectively read-only view. |
| 48 | + """ |
| 49 | + |
| 50 | + hook_name: str = "" |
| 51 | + |
| 52 | + step: int = 0 |
| 53 | + epoch: int = 0 |
| 54 | + total_samples: int = 0 |
| 55 | + total_tokens: int = 0 |
| 56 | + |
| 57 | + loss: float | None = None |
| 58 | + learning_rate: float | None = None |
| 59 | + grad_norm: float | None = None |
| 60 | + elapsed_time: float | None = None |
| 61 | + overall_throughput: float | None = None |
| 62 | + cuda_mem_allocated: float | None = None |
| 63 | + |
| 64 | + batch_metrics: dict[str, Any] = field(default_factory=dict) |
| 65 | + val_metrics: dict[str, Any] = field(default_factory=dict) |
| 66 | + checkpoint_path: str | None = None |
| 67 | + |
| 68 | + output_dir: str = "" |
| 69 | + model_name_or_path: str = "" |
| 70 | + max_epochs: int = 0 |
| 71 | + world_size: int = 1 |
| 72 | + is_local_process_zero: bool = True |
| 73 | + is_world_process_zero: bool = True |
| 74 | + |
| 75 | + |
| 76 | +_CONTEXT_FIELD_NAMES = frozenset(f.name for f in dataclasses.fields(TrainingContext)) |
| 77 | + |
| 78 | + |
| 79 | +class TrainerCallback: |
| 80 | + """Base class for training callbacks. Subclass and override hooks you need. |
| 81 | +
|
| 82 | + All methods are no-ops by default. Callbacks receive a TrainingContext |
| 83 | + snapshot and are purely observational (they cannot affect training flow). |
| 84 | + Callbacks fire on all ranks; use context.is_world_process_zero or |
| 85 | + context.is_local_process_zero to gate rank-specific behavior. |
| 86 | +
|
| 87 | + Note: on_before_forward and on_after_backward fire once per microbatch |
| 88 | + inside the gradient accumulation loop, not once per training step. |
| 89 | +
|
| 90 | + Callbacks must be self-contained for serialization across the torchrun |
| 91 | + subprocess boundary: all imports must be inside method bodies, and |
| 92 | + constructors must work with no arguments (or all-default arguments). |
| 93 | + """ |
| 94 | + |
| 95 | + def on_train_begin(self, context: TrainingContext) -> None: |
| 96 | + pass |
| 97 | + |
| 98 | + def on_epoch_begin(self, context: TrainingContext) -> None: |
| 99 | + pass |
| 100 | + |
| 101 | + def on_step_begin(self, context: TrainingContext) -> None: |
| 102 | + pass |
| 103 | + |
| 104 | + def on_before_forward(self, context: TrainingContext) -> None: |
| 105 | + pass |
| 106 | + |
| 107 | + def on_after_backward(self, context: TrainingContext) -> None: |
| 108 | + pass |
| 109 | + |
| 110 | + def on_pre_optimizer_step(self, context: TrainingContext) -> None: |
| 111 | + pass |
| 112 | + |
| 113 | + def on_optimizer_step(self, context: TrainingContext) -> None: |
| 114 | + pass |
| 115 | + |
| 116 | + def on_log(self, context: TrainingContext) -> None: |
| 117 | + pass |
| 118 | + |
| 119 | + def on_evaluate(self, context: TrainingContext) -> None: |
| 120 | + pass |
| 121 | + |
| 122 | + def on_save(self, context: TrainingContext) -> None: |
| 123 | + pass |
| 124 | + |
| 125 | + def on_step_end(self, context: TrainingContext) -> None: |
| 126 | + pass |
| 127 | + |
| 128 | + def on_epoch_end(self, context: TrainingContext) -> None: |
| 129 | + pass |
| 130 | + |
| 131 | + def on_train_end(self, context: TrainingContext) -> None: |
| 132 | + pass |
| 133 | + |
| 134 | + |
| 135 | +class CallbackManager: |
| 136 | + """Dispatches lifecycle hooks to registered TrainerCallback instances.""" |
| 137 | + |
| 138 | + def __init__(self): |
| 139 | + self._callbacks: list[TrainerCallback] = [] |
| 140 | + self.context = TrainingContext() |
| 141 | + |
| 142 | + self._loop = asyncio.new_event_loop() |
| 143 | + self._thread = threading.Thread(target=self._run_event_loop, daemon=True) |
| 144 | + self._thread.start() |
| 145 | + |
| 146 | + def _run_event_loop(self): |
| 147 | + asyncio.set_event_loop(self._loop) |
| 148 | + self._loop.run_forever() |
| 149 | + |
| 150 | + def add_callback(self, callback: TrainerCallback) -> None: |
| 151 | + if not isinstance(callback, TrainerCallback): |
| 152 | + raise TypeError( |
| 153 | + f"Expected a TrainerCallback instance, got " |
| 154 | + f"{type(callback).__name__}. " |
| 155 | + f"Pass an instance, not a class: callbacks=[MyCallback()]" |
| 156 | + ) |
| 157 | + self._callbacks.append(callback) |
| 158 | + |
| 159 | + def remove_callback(self, callback_or_type) -> None: |
| 160 | + if isinstance(callback_or_type, type): |
| 161 | + self._callbacks = [ |
| 162 | + cb for cb in self._callbacks if not isinstance(cb, callback_or_type) |
| 163 | + ] |
| 164 | + else: |
| 165 | + self._callbacks = [ |
| 166 | + cb for cb in self._callbacks if cb is not callback_or_type |
| 167 | + ] |
| 168 | + |
| 169 | + def fire(self, hook_name: str, **kwargs) -> None: |
| 170 | + if hook_name not in HOOK_NAMES: |
| 171 | + raise ValueError(f"Unknown hook: '{hook_name}'. Valid hooks: {HOOK_NAMES}") |
| 172 | + if self._loop.is_closed(): |
| 173 | + return |
| 174 | + if not self.has_callbacks(hook_name): |
| 175 | + return |
| 176 | + |
| 177 | + snapshot = copy.copy(self.context) |
| 178 | + snapshot.hook_name = hook_name |
| 179 | + snapshot.batch_metrics = dict(snapshot.batch_metrics) |
| 180 | + snapshot.val_metrics = dict(snapshot.val_metrics) |
| 181 | + for key, value in kwargs.items(): |
| 182 | + if key not in _CONTEXT_FIELD_NAMES: |
| 183 | + raise ValueError( |
| 184 | + f"Unknown TrainingContext field: '{key}'. Valid fields: {sorted(_CONTEXT_FIELD_NAMES)}" |
| 185 | + ) |
| 186 | + setattr(snapshot, key, value) |
| 187 | + |
| 188 | + for callback in self._callbacks: |
| 189 | + method = getattr(callback, hook_name) |
| 190 | + if getattr(type(callback), hook_name) is getattr( |
| 191 | + TrainerCallback, hook_name |
| 192 | + ): |
| 193 | + continue |
| 194 | + cb_snapshot = copy.copy(snapshot) |
| 195 | + cb_snapshot.batch_metrics = dict(snapshot.batch_metrics) |
| 196 | + cb_snapshot.val_metrics = dict(snapshot.val_metrics) |
| 197 | + future = asyncio.run_coroutine_threadsafe( |
| 198 | + self._safe_invoke(method, cb_snapshot), self._loop |
| 199 | + ) |
| 200 | + if hook_name == "on_train_end": |
| 201 | + try: |
| 202 | + future.result(timeout=10) |
| 203 | + except TimeoutError: |
| 204 | + logger.warning( |
| 205 | + "Callback %s.%s timed out during on_train_end (10s limit).", |
| 206 | + type(callback).__name__, |
| 207 | + hook_name, |
| 208 | + ) |
| 209 | + except Exception: |
| 210 | + logger.warning( |
| 211 | + "Callback %s.%s failed during on_train_end.", |
| 212 | + type(callback).__name__, |
| 213 | + hook_name, |
| 214 | + exc_info=True, |
| 215 | + ) |
| 216 | + |
| 217 | + async def _safe_invoke(self, method, context: TrainingContext) -> None: |
| 218 | + try: |
| 219 | + result = method(context) |
| 220 | + if asyncio.iscoroutine(result): |
| 221 | + await result |
| 222 | + except Exception: |
| 223 | + logger.exception( |
| 224 | + "Callback %s.%s raised an exception (hook=%s, step=%d). " |
| 225 | + "This exception is suppressed and will not affect training.", |
| 226 | + type(method.__self__).__name__ |
| 227 | + if hasattr(method, "__self__") |
| 228 | + else repr(method), |
| 229 | + method.__name__, |
| 230 | + context.hook_name, |
| 231 | + context.step, |
| 232 | + ) |
| 233 | + |
| 234 | + def has_callbacks(self, hook_name: str) -> bool: |
| 235 | + base_method = getattr(TrainerCallback, hook_name) |
| 236 | + return any( |
| 237 | + getattr(type(cb), hook_name) is not base_method for cb in self._callbacks |
| 238 | + ) |
| 239 | + |
| 240 | + def close(self) -> None: |
| 241 | + """Shut down the background event loop and thread.""" |
| 242 | + if self._loop.is_closed(): |
| 243 | + return |
| 244 | + try: |
| 245 | + pending = asyncio.all_tasks(self._loop) |
| 246 | + except RuntimeError: |
| 247 | + pending = set() |
| 248 | + if pending: |
| 249 | + |
| 250 | + async def _drain(): |
| 251 | + await asyncio.gather(*pending, return_exceptions=True) |
| 252 | + |
| 253 | + future = asyncio.run_coroutine_threadsafe(_drain(), self._loop) |
| 254 | + try: |
| 255 | + future.result(timeout=5) |
| 256 | + except Exception: |
| 257 | + pass |
| 258 | + self._loop.call_soon_threadsafe(self._loop.stop) |
| 259 | + self._thread.join(timeout=5) |
| 260 | + if not self._thread.is_alive() and not self._loop.is_closed(): |
| 261 | + self._loop.close() |
| 262 | + |
| 263 | + |
| 264 | +def serialize_callback(callback: TrainerCallback) -> str: |
| 265 | + """Serialize a TrainerCallback subclass to a base64 string. |
| 266 | +
|
| 267 | + The class must be self-contained: all imports must be inside method |
| 268 | + bodies. The constructor must work with no arguments (or all defaults). |
| 269 | + """ |
| 270 | + source = inspect.getsource(type(callback)) |
| 271 | + source = textwrap.dedent(source) |
| 272 | + return base64.b64encode(source.encode("utf-8")).decode("ascii") |
| 273 | + |
| 274 | + |
| 275 | +def deserialize_callback(encoded: str) -> TrainerCallback: |
| 276 | + """Reconstruct a TrainerCallback instance from a base64-encoded class source.""" |
| 277 | + source = base64.b64decode(encoded).decode("utf-8") |
| 278 | + namespace: dict[str, Any] = { |
| 279 | + "TrainerCallback": TrainerCallback, |
| 280 | + "TrainingContext": TrainingContext, |
| 281 | + } |
| 282 | + # Only called with source from run_training() serialization, never untrusted input |
| 283 | + exec(source, namespace) # noqa: S102 # pylint: disable=exec-used |
| 284 | + classes = [ |
| 285 | + v |
| 286 | + for v in namespace.values() |
| 287 | + if isinstance(v, type) |
| 288 | + and issubclass(v, TrainerCallback) |
| 289 | + and v is not TrainerCallback |
| 290 | + ] |
| 291 | + if len(classes) != 1: |
| 292 | + raise ValueError( |
| 293 | + f"Expected exactly one TrainerCallback subclass, got {len(classes)}." |
| 294 | + ) |
| 295 | + return classes[0]() |
| 296 | + |
| 297 | + |
| 298 | +def serialize_callbacks_for_cli( |
| 299 | + callbacks: list[TrainerCallback], |
| 300 | +) -> str: |
| 301 | + """Serialize a list of callbacks to a base64 string for CLI transport.""" |
| 302 | + serialized = [serialize_callback(cb) for cb in callbacks] |
| 303 | + return base64.b64encode(json.dumps(serialized).encode("utf-8")).decode("ascii") |
| 304 | + |
| 305 | + |
| 306 | +def deserialize_callbacks_from_cli( |
| 307 | + encoded: str, |
| 308 | +) -> list[TrainerCallback]: |
| 309 | + """Reconstruct TrainerCallback instances from a CLI-transported base64 string.""" |
| 310 | + decoded = json.loads(base64.b64decode(encoded).decode("utf-8")) |
| 311 | + return [deserialize_callback(s) for s in decoded] |
0 commit comments