Skip to content

Commit da76404

Browse files
authored
Merge pull request #706 from hrathina/feat/callback-mechanism
feat: add class-based callback system for training lifecycle hooks
2 parents b2c1070 + f9b4ee2 commit da76404

6 files changed

Lines changed: 1042 additions & 13 deletions

File tree

src/instructlab/training/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"LoraOptions",
66
"QuantizeDataType",
77
"TorchrunArgs",
8+
"TrainerCallback",
89
"TrainingArgs",
10+
"TrainingContext",
911
"run_training",
1012
"FSDPOptions",
1113
"ShardingStrategies",
@@ -17,6 +19,7 @@
1719
import instructlab.training.logger # Disable package logging by default
1820

1921
# Local
22+
from .callbacks import TrainerCallback, TrainingContext
2023
from .config import (
2124
DataProcessArgs,
2225
DeepSpeedOffloadStrategy,

src/instructlab/training/batch_loss_manager.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,14 @@ class BatchLossManager:
4848
- Computing average losses for logging
4949
"""
5050

51-
def __init__(self, model, accelerator, world_size: int, local_rank: int):
51+
def __init__(
52+
self,
53+
model,
54+
accelerator,
55+
world_size: int,
56+
local_rank: int,
57+
callback_manager=None,
58+
):
5259
"""
5360
Initialize the BatchLossManager.
5461
@@ -57,12 +64,14 @@ def __init__(self, model, accelerator, world_size: int, local_rank: int):
5764
accelerator: The accelerator instance for distributed training
5865
world_size: Number of distributed processes
5966
local_rank: Local rank of the current process
67+
callback_manager: Optional CallbackManager for lifecycle hooks
6068
"""
6169
self.model: Model = model
6270
self.accelerator: Accelerator = accelerator
6371
self.world_size: int = world_size
6472
self.local_rank: int = local_rank
6573
self.torch_device = torch.device("cuda", local_rank)
74+
self.callback_manager = callback_manager
6675

6776
def process_batch(
6877
self,
@@ -111,6 +120,9 @@ def process_batch(
111120
batch_total_samples += micro_batch_size
112121
batch_total_length += total_length
113122

123+
if self.callback_manager:
124+
self.callback_manager.fire("on_before_forward")
125+
114126
# prepare model inputs
115127
model_inputs = self._prepare_model_inputs(mb)
116128

@@ -126,6 +138,9 @@ def process_batch(
126138

127139
self.accelerator.backward(scaled_loss)
128140

141+
if self.callback_manager:
142+
self.callback_manager.fire("on_after_backward")
143+
129144
# accumulate losses
130145
grad_accum_steps += 1
131146
accumulated_loss += raw_losses.main_loss
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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

Comments
 (0)