Coverage for / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / core / optimizer / lr_scheduler.py: 0%
146 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 05:41 +0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 05:41 +0800
1# Copyright 2026 Huawei Technologies Co., Ltd
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# ============================================================================
16"""Learning rate schedule utilities for HyperParallel optimizers."""
18import copy
19import logging
20import math
21from typing import Any, Dict, Iterator, List, Optional
23from hyper_parallel.core.optimizer.optimizer import ChainedOptimizer
25logger = logging.getLogger(__name__)
27SUPPORTED_DECAY_STYLES = {"constant", "linear", "cosine", "WSD"}
28SUPPORTED_WSD_STYLES = {"linear", "cosine", "exponential", "minus_sqrt"}
31class OptimizerParamScheduler:
32 """Anneals learning rate and weight decay for a SINGLE optimizer.
34 Aligned with LambdaLR-based scheduler:
35 - LR values are bit-identical for constant / linear / cosine decay styles.
36 - Different param groups (e.g. muon vs adamw) naturally get different peak LRs
37 via ``param_group['initial_lr']`` (set by optimizer constructor), without
38 injecting extra keys into param_groups.
39 """
41 def __init__(
42 self,
43 optimizer: Any,
44 init_lr: float,
45 lr_start: float,
46 min_lr: float,
47 lr_warmup_steps: int,
48 lr_decay_steps: int,
49 lr_decay_style: str,
50 lr_decay_ratio: float = 1.0,
51 wsd_decay_steps: Optional[int] = None,
52 lr_wsd_decay_style: str = "exponential",
53 override_opt_param_scheduler: bool = False
54 ):
55 self.optimizer = optimizer
56 self.init_lr = init_lr
57 self.lr_start = lr_start
58 self.min_lr = min_lr
59 self.lr_warmup_steps = lr_warmup_steps
60 self.lr_decay_steps = lr_decay_steps
61 self.lr_decay_style = lr_decay_style
62 self.lr_decay_ratio = lr_decay_ratio
64 self.wsd_decay_steps = wsd_decay_steps
65 self.lr_wsd_decay_style = lr_wsd_decay_style
67 self.override_opt_param_scheduler = override_opt_param_scheduler
68 self.num_steps = 0
70 # Ensure every param_group has 'initial_lr' (same as PyTorch LambdaLR does).
71 # This is the peak LR for each group — set by the optimizer constructor
72 # (e.g. muon lr=0.001, adamw lr=0.0001), so different sub-optimizers
73 # naturally get different peak LRs without injecting extra keys.
74 for pg in self.optimizer.param_groups:
75 pg.setdefault('initial_lr', pg['lr'])
77 self._validate_params()
79 # Set the learning rate
80 self.step(0)
82 def _validate_params(self) -> None:
83 """Validate initialization parameters to ensure logical correctness."""
84 if self.min_lr < 0.0:
85 raise ValueError("min_lr must be >= 0.0")
87 if self.init_lr < self.min_lr:
88 raise ValueError("init_lr must be >= min_lr")
90 if self.lr_decay_steps <= 0:
91 raise ValueError("lr_decay_steps must be > 0")
93 if self.lr_warmup_steps >= self.lr_decay_steps:
94 raise ValueError("warmup_steps must be < decay_steps")
96 if self.lr_decay_style == "WSD":
97 if self.wsd_decay_steps is None:
98 raise ValueError("wsd_decay_steps must be not None")
100 if self.wsd_decay_steps <= 0:
101 raise ValueError("wsd_decay_steps must be > 0")
103 if self.wsd_decay_steps > self.lr_decay_steps:
104 raise ValueError("wsd_decay_steps must be <= lr_decay_steps")
106 def get_lr(self, param_group: Dict[str, Any]) -> float:
107 """Calculate and return the learning rate based on current step and decay style."""
108 max_lr = param_group['initial_lr']
109 init_lr = self.init_lr
110 min_lr_ratio = self.min_lr / init_lr if init_lr > 0 else 0.0
112 # 1. Linear warmup (exclusive boundary: step < warmup_steps, same as origin).
113 if self.lr_warmup_steps > 0 and self.num_steps < self.lr_warmup_steps:
114 progress = float(self.num_steps) / float(self.lr_warmup_steps)
115 factor = (self.lr_start + (init_lr - self.lr_start) * progress) / init_lr
116 return factor * max_lr
118 # If the learning rate is constant, just return the peak value.
119 if self.lr_decay_style == 'constant':
120 return max_lr
122 # 2. Decay period
123 # 2.1 WSD decay
124 if self.lr_decay_style == 'WSD':
125 # WSD: Warmup -> Stable -> Decay
126 # For WSD, lr_decay_steps is the total schedule length (no lr_decay_ratio applied).
127 if self.num_steps > self.lr_decay_steps:
128 return min_lr_ratio * max_lr
130 wsd_anneal_start = self.lr_decay_steps - (self.wsd_decay_steps or 0)
132 if self.num_steps <= wsd_anneal_start:
133 return max_lr # Stable Phase: keep max_lr without decaying
135 # Final decay phase of WSD
136 wsd_decay_ratio = float(self.num_steps - wsd_anneal_start) / float(self.wsd_decay_steps or 1)
138 if self.lr_wsd_decay_style == "linear":
139 coeff = 1.0 - wsd_decay_ratio
140 elif self.lr_wsd_decay_style == "cosine":
141 coeff = 0.5 * (math.cos(math.pi * wsd_decay_ratio) + 1.0)
142 elif self.lr_wsd_decay_style == "exponential":
143 coeff = (2.0 * (0.5 ** wsd_decay_ratio)) - 1.0
144 else: # minus_sqrt fallback
145 coeff = 1.0 - math.sqrt(wsd_decay_ratio)
147 factor = max(0.0, coeff) * (1 - min_lr_ratio) + min_lr_ratio
148 return factor * max_lr
150 # 2.2 Non-WSD decay: use lr_decay_ratio to compute effective decay_steps
151 lr_decay_steps = int(self.lr_decay_steps * self.lr_decay_ratio)
152 if self.num_steps > lr_decay_steps:
153 return min_lr_ratio * max_lr
155 decay_ratio = float(self.num_steps - self.lr_warmup_steps) / float(
156 max(1, lr_decay_steps - self.lr_warmup_steps)
157 )
159 decay_ratio = max(0.0, min(1.0, decay_ratio))
161 if self.lr_decay_style == 'linear':
162 factor = max(min_lr_ratio, 1.0 - decay_ratio)
163 return factor * max_lr
165 if self.lr_decay_style == 'cosine':
166 coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
167 factor = coeff * (1 - min_lr_ratio) + min_lr_ratio
168 return max(0.0, factor) * max_lr
170 raise ValueError(f"Unsupported decay style: {self.lr_decay_style}")
172 def step(self, increment: int = 1) -> None:
173 """Advance the scheduler steps and set lr for all parameters groups."""
174 self.num_steps += increment
175 for param_group in self.optimizer.param_groups:
176 param_group['lr'] = self.get_lr(param_group)
178 def get_last_lr(self) -> List[float]:
179 """Return the current learning rate for all parameter groups."""
180 return [group['lr'] for group in self.optimizer.param_groups]
182 def state_dict(self) -> Dict[str, Any]:
183 """Return the state of the scheduler as a dict."""
184 num_groups = len(self.optimizer.param_groups)
186 # LambdaLR.load_state_dict compatible layout
187 state_dict = {
188 "last_epoch": self.num_steps,
189 "base_lrs": [pg['initial_lr'] for pg in self.optimizer.param_groups],
190 "_last_lr": [pg['lr'] for pg in self.optimizer.param_groups],
191 "_step_count": self.num_steps + 1,
192 "lr_lambdas": [None] * num_groups,
193 }
195 # OptimizerParamScheduler config
196 state_dict.update({
197 "initial_lr": self.init_lr,
198 "lr_warmup_steps": self.lr_warmup_steps,
199 "lr_decay_steps": self.lr_decay_steps,
200 "lr_decay_style": self.lr_decay_style,
201 "lr_decay_ratio": self.lr_decay_ratio,
202 "min_lr": self.min_lr,
203 "wsd_decay_steps": self.wsd_decay_steps,
204 "lr_wsd_decay_style": self.lr_wsd_decay_style
205 })
207 return state_dict
209 def _check_and_set(self, cls_value: Any, sd_value: Any, name: str) -> Any:
210 """Strong validation logic during checkpoint loading."""
211 if self.override_opt_param_scheduler:
212 return cls_value
213 if cls_value != sd_value:
214 logger.warning("Scheduler Config Override: %s changed from %s to %s.", name, sd_value, cls_value)
215 return cls_value
217 def load_state_dict(self, state_dict: Dict[str, Any]) -> None:
218 """Load state and immediately refresh the underlying optimizer's learning rate.
220 Compatible with both:
221 - OptimizerParamScheduler state dicts (last_epoch, lr_warmup_steps, ...)
222 - PyTorch LambdaLR state dicts (last_epoch, base_lrs, _step_count, ...)
223 """
224 # Restore num_steps from either format
225 if 'last_epoch' in state_dict:
226 self.num_steps = state_dict['last_epoch']
227 elif 'num_steps' in state_dict:
228 self.num_steps = state_dict['num_steps']
229 else:
230 self.num_steps = 0
232 # Restore scheduler config if present (OptimizerParamScheduler format only;
233 # LambdaLR checkpoints lack these keys and will be silently skipped).
234 config_keys = [
235 ('lr_warmup_steps', 'warmup_steps'),
236 ('lr_decay_steps', 'decay_steps'),
237 ('lr_decay_style', 'decay_style'),
238 ('lr_decay_ratio', 'decay_ratio'),
239 ('min_lr', 'min_lr'),
240 ('init_lr', 'initial_lr'),
241 ('wsd_decay_steps', 'wsd_decay_steps'),
242 ('lr_wsd_decay_style', 'lr_wsd_decay_style'),
243 ]
244 for attr, log_name in config_keys:
245 if attr in state_dict:
246 setattr(self, attr, self._check_and_set(getattr(self, attr), state_dict[attr], log_name))
248 # Recompute LR for current step without advancing the counter
249 self.step(0)
252class LRSchedulersContainer:
253 """Container for multiple learning rate schedulers.
255 Each scheduler is keyed by the same name as its corresponding sub-optimizer
256 in ``ChainedOptimizer.optimizers_dict`` (e.g. ``"muon"``, ``"adamw"``).
257 This ensures that ``state_dict`` / ``load_state_dict`` are robust to
258 insertion-order differences between the save and load environments.
259 """
261 def __init__(self, optimizers: ChainedOptimizer, scheduler_kwargs: Dict[str, Any]) -> None:
262 self._names: List[str] = list(optimizers.optimizers_dict.keys())
263 self._schedulers_by_name: Dict[str, OptimizerParamScheduler] = {}
264 for name, opt in optimizers.optimizers_dict.items():
265 self._schedulers_by_name[name] = OptimizerParamScheduler(
266 optimizer=opt, **scheduler_kwargs,
267 )
268 self.schedulers: List[OptimizerParamScheduler] = [
269 self._schedulers_by_name[name] for name in self._names
270 ]
272 def __iter__(self) -> Iterator[OptimizerParamScheduler]:
273 """Iterate over the registered schedulers."""
274 return iter(self.schedulers)
276 def __len__(self) -> int:
277 """Return the total number of schedulers in the container."""
278 return len(self.schedulers)
280 def step(self) -> None:
281 """Advance the step for all schedulers."""
282 for scheduler in self.schedulers:
283 scheduler.step()
285 def get_last_lr(self) -> List[float]:
286 """Return a flattened list of the last learning rates across all sub-schedulers."""
287 param_last_lr: List[float] = []
288 for scheduler in self.schedulers:
289 param_last_lr.extend(scheduler.get_last_lr())
290 return param_last_lr
292 def state_dict(self) -> Dict[str, Any]:
293 """Return scheduler states keyed by sub-optimizer name.
295 Compatible with veomni's ``{'muon': ..., 'adamw': ...}`` format.
296 """
297 return {name: self._schedulers_by_name[name].state_dict() for name in self._names}
299 def load_state_dict(self, state_dict: Dict[str, Any]) -> None:
300 """Load scheduler states keyed by sub-optimizer name.
302 Matches by name rather than position, so the checkpoint key order
303 (e.g. ``muon, adamw``) need not match the local creation order
304 (e.g. ``adamw, muon``).
305 """
306 if len(self._names) != len(state_dict):
307 raise RuntimeError(
308 f"Scheduler count mismatch! Current has {len(self._names)}, "
309 f"but checkpoint contains {len(state_dict)} states."
310 )
311 for name in self._names:
312 if name not in state_dict:
313 raise RuntimeError(
314 f"Missing state for scheduler '{name}' in state_dict. "
315 f"Available keys: {sorted(state_dict.keys())}, "
316 f"expected keys: {sorted(self._names)}."
317 )
318 self._schedulers_by_name[name].load_state_dict(
319 copy.deepcopy(state_dict[name]),
320 )
323def get_hyper_lr_scheduler(
324 optimizer: ChainedOptimizer,
325 total_steps: int,
326 warmup_steps: int = 0,
327 warmup_ratio: float = 0.0,
328 decay_style: str = "cosine",
329 lr: float = 1e-4,
330 lr_min: float = 1e-7,
331 lr_start: float = 0.0,
332 lr_decay_ratio: float = 1.0,
333 wsd_decay_steps: Optional[int] = None,
334 lr_wsd_decay_style: str = "exponential",
335 override_opt_param_scheduler: bool = False,
336) -> LRSchedulersContainer:
337 """Create a learning rate scheduler compatible with HyperParallel optimizers.
339 Example:
340 from hyper_parallel.core.optimizer.lr_scheduler import get_hyper_lr_scheduler
342 lr_scheduler = get_hyper_lr_scheduler(
343 optimizer=optimizer,
344 total_steps=train_steps,
345 warmup_steps=0,
346 warmup_ratio=lr_warmup_ratio,
347 decay_style=lr_decay_style,
348 lr_decay_ratio=lr_decay_ratio,
349 lr_min=lr_min,
350 lr=lr,
351 lr_start=lr_start,
352 )
353 return lr_scheduler
354 """
356 if decay_style not in SUPPORTED_DECAY_STYLES:
357 raise ValueError(
358 f"Unknown decay_style '{decay_style}'. "
359 f"Supported: {sorted(SUPPORTED_DECAY_STYLES)}"
360 )
362 if decay_style == "WSD" and lr_wsd_decay_style not in SUPPORTED_WSD_STYLES:
363 raise ValueError(
364 f"Unknown lr_wsd_decay_style '{lr_wsd_decay_style}'. "
365 f"Supported: {sorted(SUPPORTED_WSD_STYLES)}"
366 )
368 # pylint: disable=chained-comparison
369 if warmup_steps <= 0 and warmup_ratio > 0:
370 warmup_steps = int(total_steps * warmup_ratio)
372 scheduler_kwargs = {
373 "init_lr": lr,
374 "lr_start": lr_start,
375 "min_lr": lr_min,
376 "lr_warmup_steps": warmup_steps,
377 "lr_decay_steps": total_steps,
378 "lr_decay_style": decay_style,
379 "lr_decay_ratio": lr_decay_ratio,
380 "wsd_decay_steps": wsd_decay_steps,
381 "lr_wsd_decay_style": lr_wsd_decay_style,
382 "override_opt_param_scheduler": override_opt_param_scheduler,
383 }
385 return LRSchedulersContainer(optimizers=optimizer, scheduler_kwargs=scheduler_kwargs)