Skip to content

Commit 7b4a493

Browse files
A9ishaGoogle-ML-Automation
authored andcommitted
Create single-step SPMD trainer
PiperOrigin-RevId: 952918614
1 parent 38b235c commit 7b4a493

7 files changed

Lines changed: 1554 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Trainer abstractions.
16+
17+
Defines the core Trainer interface, data payload interfaces, and on-device
18+
metrics structures (WeightedMetric, MetricsBuffer) used by the training loop.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import abc
24+
from collections.abc import Callable
25+
import dataclasses
26+
from typing import Any
27+
28+
import flax.struct
29+
import jax
30+
from jax.typing import ArrayLike # pylint: disable=g-importing-member
31+
32+
33+
@flax.struct.dataclass
34+
class WeightedMetric:
35+
"""A metric that requires weighted reduction.
36+
37+
Attributes:
38+
unreduced_sum: Sum of the metric values across tokens/examples.
39+
denominator: Weight or count of valid tokens/examples.
40+
eps: Optional epsilon added to denominator for numerical stability.
41+
min_denom: Optional minimum bound for the denominator.
42+
"""
43+
44+
unreduced_sum: jax.Array
45+
denominator: jax.Array
46+
eps: float | None = flax.struct.field(default=None, pytree_node=False)
47+
min_denom: float | None = flax.struct.field(default=None, pytree_node=False)
48+
49+
def compute_scale(self) -> jax.Array:
50+
"""Safely computes the scale factor (1 / denominator) with bounds.
51+
52+
Returns:
53+
Safe scaling factor array preventing division-by-zero NaNs.
54+
"""
55+
denom = self.denominator
56+
if self.min_denom is not None:
57+
denom = jax.numpy.maximum(denom, self.min_denom)
58+
if self.eps is not None:
59+
denom = denom + self.eps
60+
safe_denom = jax.numpy.where(denom == 0, 1.0, denom)
61+
scale = 1.0 / safe_denom
62+
return jax.numpy.where(denom == 0, 0.0, scale)
63+
64+
def compute(self) -> jax.Array:
65+
"""Safely computes total / count with numerical stability bounds.
66+
67+
Returns:
68+
Reduced metric array equal to unreduced_sum * compute_scale().
69+
"""
70+
return self.unreduced_sum * self.compute_scale()
71+
72+
73+
@flax.struct.dataclass
74+
class MetricsBuffer:
75+
"""A buffer for storing and aggregating unreduced metrics on-device.
76+
77+
Attributes:
78+
id: Identifier for the buffer (e.g., training iteration or step index).
79+
weighted_metrics: Dictionary of WeightedMetric objects on accelerator HBM.
80+
scalar_metrics: Dictionary of scalar JAX arrays on accelerator HBM.
81+
aggregation_fns: Host-side reduction/aggregation callbacks (untraced).
82+
mode: Execution mode string ("train" or "eval").
83+
"""
84+
85+
id: Any
86+
weighted_metrics: dict[str, WeightedMetric] = flax.struct.field(
87+
default_factory=dict
88+
)
89+
scalar_metrics: dict[str, jax.Array] = flax.struct.field(default_factory=dict)
90+
aggregation_fns: dict[str, Callable[[jax.Array], Any]] = flax.struct.field(
91+
default_factory=dict, pytree_node=False
92+
)
93+
mode: str = flax.struct.field(default="train", pytree_node=False)
94+
95+
96+
@dataclasses.dataclass(kw_only=True)
97+
class TrainerPayload(abc.ABC):
98+
"""Base class for packed micro-batches ready for gradient descent.
99+
100+
The base carries only what generic machinery must read to stay
101+
algorithm-agnostic. Algorithm-specific tensors live on subclasses and are
102+
reached by the trainer's gen_model_input_fn, not by the generic loop. Users
103+
subclass this to carry their own fields.
104+
105+
Attributes:
106+
token_ids: [B, T] token IDs. By default, structured as left-padded prompt
107+
tokens concatenated with right-padded completion tokens.
108+
token_mask: [B, T] token mask to differentiate padding tokens from valid
109+
tokens.
110+
segment_ids: Optional [B, T] packing segment ids.
111+
"""
112+
113+
token_ids: ArrayLike
114+
token_mask: ArrayLike
115+
segment_ids: ArrayLike | None = None
116+
117+
118+
@dataclasses.dataclass
119+
class TrainingConfig:
120+
"""Configuration for the abstract trainer.
121+
122+
Defines standard hyperparameters and operational settings for the ML training
123+
loop.
124+
"""
125+
126+
eval_every_n_steps: int = 0
127+
max_steps: int | None = None
128+
gradient_accumulation_steps: int | None = None
129+
checkpoint_root_directory: str | None = None
130+
metrics_prefix: str = ""
131+
max_inflight_computations: int = 2
132+
133+
134+
class AbstractTrainingEngine(abc.ABC):
135+
"""Core trainer interface executing model updates and Multi-Tier Checkpointing.
136+
137+
The Trainer owns the model weights in accelerator HBM and executes forward/
138+
backward passes, weight updates, evaluation steps, and checkpoint saving/
139+
restoring.
140+
"""
141+
142+
@abc.abstractmethod
143+
def __init__(self, training_config: TrainingConfig) -> None:
144+
"""Initializes the Trainer based on the training configuration.
145+
146+
Args:
147+
training_config: Training hyperparameters and runtime configuration.
148+
"""
149+
150+
@abc.abstractmethod
151+
def with_loss_fn(self, customized_fn: Callable[..., Any]) -> None:
152+
"""Updates the trainer's loss function.
153+
154+
Args:
155+
customized_fn: Custom loss function callable.
156+
"""
157+
158+
@abc.abstractmethod
159+
def with_gen_model_input_fn(
160+
self, gen_model_input_fn: Callable[[Any], dict[str, Any]]
161+
) -> "AbstractTrainingEngine":
162+
"""Sets the last-mile adapter mapping a payload to the loss fn's kwargs.
163+
164+
This adapter enables the trainer to accept arbitrary payloads (SFT, RL,
165+
etc.) by transforming them into kwargs for the loss function via
166+
`gen_model_input_fn(payload)`.
167+
Args:
168+
gen_model_input_fn: Maps a payload to a dict of loss-fn keyword arguments.
169+
170+
Returns:
171+
self, for chaining.
172+
"""
173+
174+
@abc.abstractmethod
175+
def compile(self, dummy_data: TrainerPayload) -> None:
176+
"""Triggers JAX compilation. `with_loss_fn` must be called first.
177+
178+
Args:
179+
dummy_data: Payload with representative shapes used for JAX tracing.
180+
"""
181+
182+
@abc.abstractmethod
183+
def fwd_bwd(self, payload: TrainerPayload) -> None:
184+
"""Executes forward and backward passes.
185+
186+
Metrics are cached to overlap train steps.
187+
188+
Args:
189+
payload: Packed micro-batch payload for training.
190+
"""
191+
192+
@abc.abstractmethod
193+
def update(self) -> None:
194+
"""Executes a model weight update step using accumulated gradients."""
195+
196+
@abc.abstractmethod
197+
def eval_step(self, payload: TrainerPayload, **kwargs: Any) -> None:
198+
"""Executes one evaluation step on the given payload.
199+
200+
Args:
201+
payload: Packed micro-batch payload for evaluation.
202+
**kwargs: Additional evaluation keyword arguments.
203+
"""
204+
205+
@abc.abstractmethod
206+
def save_checkpoint(self, metadata: Any, **kwargs: Any) -> None:
207+
"""Forces the trainer to serialize its state (model + optimizer).
208+
209+
Args:
210+
metadata: Checkpoint identifier or UUID metadata pytree.
211+
**kwargs: Additional checkpointing keyword arguments.
212+
"""
213+
214+
@abc.abstractmethod
215+
def restore_checkpoint(self, **kwargs: Any) -> Any:
216+
"""Restores state from latest checkpoint and returns the metadata pytree.
217+
218+
The returned metadata (e.g., global_step) matches what was stored in
219+
save_checkpoint.
220+
221+
Args:
222+
**kwargs: Additional restoration keyword arguments.
223+
224+
Returns:
225+
The metadata PyTree stored with the checkpoint.
226+
"""
227+
228+
@abc.abstractmethod
229+
def get_metrics(self, clear_cache: bool = True) -> MetricsBuffer:
230+
"""Returns cached metrics and optionally clears the metrics cache.
231+
232+
Args:
233+
clear_cache: Whether to reset cached metrics after retrieval.
234+
235+
Returns:
236+
The accumulated on-device MetricsBuffer.
237+
"""
238+
239+
@abc.abstractmethod
240+
def prepare_weight_sync(self, **kwargs: Any) -> Any:
241+
"""Stages weights for transfer and returns metadata/coordinates.
242+
243+
Args:
244+
**kwargs: Weight staging configuration parameters.
245+
246+
Returns:
247+
Synchronization endpoints or file coordinates for weight transfer.
248+
"""
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for trainer abstractions."""
16+
17+
import dataclasses
18+
from typing import Any
19+
from absl.testing import absltest
20+
import jax.numpy as jnp
21+
from maxtext.training_engine import abstract_engine
22+
23+
24+
@dataclasses.dataclass(kw_only=True)
25+
class DummyPayload(abstract_engine.TrainerPayload):
26+
"""Dummy payload for testing."""
27+
28+
data: str = "batch_1"
29+
30+
31+
class DummyTrainingEngine(abstract_engine.AbstractTrainingEngine):
32+
"""Minimal concrete implementation of AbstractTrainingEngine for unit testing."""
33+
34+
def __init__(self, training_config: abstract_engine.TrainingConfig) -> None:
35+
self.config = training_config
36+
self.loss_fn = None
37+
self.compiled = False
38+
self.fwd_bwd_called = 0
39+
self.update_called = 0
40+
self.checkpoint_saved_with_metadata: Any = None
41+
self.restored_metadata: Any = {"global_step": 42}
42+
43+
def with_loss_fn(self, customized_fn: Any) -> None:
44+
self.loss_fn = customized_fn
45+
46+
def with_gen_model_input_fn(
47+
self, gen_model_input_fn: Any
48+
) -> "DummyTrainingEngine":
49+
self._gen_model_input_fn = gen_model_input_fn
50+
return self
51+
52+
def compile(self, dummy_data: abstract_engine.TrainerPayload) -> None:
53+
self.compiled = True
54+
55+
def fwd_bwd(self, payload: abstract_engine.TrainerPayload) -> None:
56+
self.fwd_bwd_called += 1
57+
58+
def update(self) -> None:
59+
self.update_called += 1
60+
61+
def eval_step(
62+
self, payload: abstract_engine.TrainerPayload, **kwargs: Any
63+
) -> None:
64+
pass
65+
66+
def save_checkpoint(self, metadata: Any, **kwargs: Any) -> None:
67+
self.checkpoint_saved_with_metadata = metadata
68+
69+
def restore_checkpoint(self, **kwargs: Any) -> Any:
70+
return self.restored_metadata
71+
72+
def get_metrics(
73+
self, clear_cache: bool = True
74+
) -> abstract_engine.MetricsBuffer:
75+
return abstract_engine.MetricsBuffer(id=1)
76+
77+
def prepare_weight_sync(self, **kwargs: Any) -> Any:
78+
return {"endpoint": "grpc://dummy-trainer:55555"}
79+
80+
81+
class AbstractTrainingEngineTest(absltest.TestCase):
82+
83+
def test_abstract_training_engine_cannot_be_instantiated_directly(self):
84+
config = abstract_engine.TrainingConfig()
85+
with self.assertRaises(TypeError):
86+
abstract_engine.AbstractTrainingEngine(config) # pytype: disable=not-instantiable
87+
88+
def test_dummy_training_engine_implements_abstract_interface(self):
89+
config = abstract_engine.TrainingConfig(max_steps=100)
90+
t = DummyTrainingEngine(config)
91+
self.assertEqual(t.config.max_steps, 100)
92+
payload = DummyPayload(
93+
data="dummy",
94+
token_ids=jnp.ones((2, 2)),
95+
token_mask=jnp.ones((2, 2)),
96+
)
97+
t.compile(payload)
98+
self.assertTrue(t.compiled)
99+
t.fwd_bwd(payload)
100+
self.assertEqual(t.fwd_bwd_called, 1)
101+
t.update()
102+
self.assertEqual(t.update_called, 1)
103+
104+
def test_weighted_metric_compute(self):
105+
m = abstract_engine.WeightedMetric(
106+
unreduced_sum=jnp.array(10.0),
107+
denominator=jnp.array(2.0),
108+
)
109+
self.assertAlmostEqual(float(m.compute()), 5.0)
110+
111+
112+
if __name__ == "__main__":
113+
absltest.main()

0 commit comments

Comments
 (0)