Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / trainer / callbacks / environ_meter_callback.py: 82%

243 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-11-15 07:18 +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# ============================================================================ 

15"""Training and environment metric collection callback.""" 

16 

17from __future__ import annotations 

18 

19import time 

20from collections.abc import Mapping, Sequence 

21from typing import Any 

22 

23from hyper_parallel.trainer.runtime.distributed import get_world_size_safe 

24from hyper_parallel.trainer.runtime.distributed import all_reduce 

25from hyper_parallel.trainer.runtime.flops import estimate_step_flops 

26from hyper_parallel.data.constants import IGNORE_INDEX 

27from hyper_parallel.trainer.runtime.device import get_device_type, get_torch_device 

28 

29from .base import Callback, TrainerState 

30 

31 

32class EnvironMeterCallback(Callback): 

33 """Collect structured training, throughput, and memory metrics. 

34 

35 The callback is the single producer of ``trainer.step_train_metrics`` and 

36 ``trainer.step_env_metrics``. Presentation and remote logging callbacks 

37 consume those dictionaries without recalculating or reducing metrics. 

38 """ 

39 

40 def __init__(self, trainer: Any, *, microbatch_mean_metrics: Sequence[str] = ()) -> None: 

41 """Initialize per-step and cumulative counters. 

42 

43 Args: 

44 trainer: Trainer that owns the callback lifecycle. 

45 microbatch_mean_metrics: Raw metric names to average over observed 

46 micro-batches before the existing DP+CP mean reduction. Other 

47 metrics retain the step values supplied by the Trainer. 

48 """ 

49 super().__init__(trainer) 

50 self._step_start_time = 0.0 

51 self._local_step_tokens: Any = None 

52 self._local_step_input_tokens: Any = None 

53 self._local_step_seq_len_sq: Any = None 

54 self._local_step_samples = 0 

55 self._microbatch_mean_metrics = tuple(microbatch_mean_metrics) 

56 self._local_mean_metrics: dict[str, tuple[Any, int]] = {} 

57 self._consumed_tokens = 0 

58 self._consumed_samples = 0 

59 self.trainer.step_train_metrics = {} 

60 self.trainer.step_env_metrics = {} 

61 

62 @staticmethod 

63 def _scalar(value: Any, name: str) -> float: 

64 """Convert a scalar or scalar tensor-like value to ``float``. 

65 

66 Args: 

67 value: Scalar value to convert. 

68 name: Metric name used in validation errors. 

69 

70 Returns: 

71 Converted floating-point value. 

72 

73 Raises: 

74 ValueError: If the value cannot be converted to a scalar float. 

75 """ 

76 item = getattr(value, "item", None) 

77 if callable(item): 

78 value = item() 

79 try: 

80 return float(value) 

81 except (TypeError, ValueError) as exc: 

82 raise ValueError(f"Metric {name!r} must be scalar, but got {value!r}") from exc 

83 

84 @staticmethod 

85 def _tensor_numel(value: Any) -> int | None: 

86 """Return ``value.numel()`` when it exposes a tensor-like interface.""" 

87 numel = getattr(value, "numel", None) 

88 if not callable(numel): 

89 return None 

90 return int(numel()) 

91 

92 @classmethod 

93 def _batch_tokens(cls, batch: Mapping[str, Any]) -> Any: 

94 """Count text tokens without synchronizing a device scalar to the host.""" 

95 token_count = batch.get("token_count") 

96 if token_count is not None: 

97 return token_count 

98 

99 labels = batch.get("labels") 

100 if labels is not None and callable(getattr(labels, "sum", None)): 

101 return (labels != IGNORE_INDEX).sum() 

102 

103 attention_mask = batch.get("attention_mask") 

104 attention_mask_shape = getattr(attention_mask, "shape", ()) 

105 if ( 

106 len(attention_mask_shape) <= 2 

107 and attention_mask is not None 

108 and callable(getattr(attention_mask, "sum", None)) 

109 ): 

110 return attention_mask.sum() 

111 

112 input_ids = batch.get("input_ids") 

113 input_numel = cls._tensor_numel(input_ids) 

114 if input_numel is not None: 

115 return input_numel 

116 return 0 

117 

118 @classmethod 

119 def _batch_input_geometry(cls, batch: Mapping[str, Any]) -> tuple[Any, Any] | None: 

120 """Return model-input tokens and the sum of squared sequence lengths. 

121 

122 The loss token count drops masked prompt tokens, but the model still 

123 runs attention and the FFN over them, so FLOPs need their own count: 

124 the tokens that reach attention plus ``sum(S ** 2)`` over the real 

125 attention boundaries. 

126 

127 Every returned quantity is local to this rank, because 

128 :meth:`on_step_end` reduces them over DP+CP itself. 

129 

130 Args: 

131 batch: One micro-batch mapping. 

132 

133 Returns: 

134 ``(input_tokens, sum_seq_len_sq)`` for the sequences that reach 

135 attention, or ``None`` when the batch exposes no input geometry. 

136 """ 

137 cu_seq_lens = batch.get("cu_seq_lens") 

138 if cu_seq_lens is not None and callable(getattr(cu_seq_lens, "diff", None)): 

139 local_tokens = cls._tensor_numel(batch.get("input_ids")) 

140 lengths = cu_seq_lens.diff() 

141 covered = int(lengths.sum()) 

142 # Packed boundaries are only usable when they describe the tokens 

143 # this rank actually holds. Under context parallelism the packed 

144 # boundaries may still span the global sequence, and then the local 

145 # ``input_ids`` fallbacks below are the honest answer. 

146 if covered > 0 and (local_tokens is None or covered == local_tokens): 

147 return lengths.sum(), (lengths * lengths).sum() 

148 

149 attention_mask = batch.get("attention_mask") 

150 if ( 

151 attention_mask is not None 

152 and len(getattr(attention_mask, "shape", ())) == 2 

153 and callable(getattr(attention_mask, "sum", None)) 

154 ): 

155 lengths = attention_mask.sum(dim=-1) 

156 return lengths.sum(), (lengths * lengths).sum() 

157 

158 shape = getattr(batch.get("input_ids"), "shape", None) 

159 if shape is None or len(shape) < 2: 

160 return None 

161 rows = int(shape[0]) 

162 seq_length = int(shape[-1]) 

163 if rows <= 0 or seq_length <= 0: 

164 return None 

165 return rows * seq_length, rows * seq_length * seq_length 

166 

167 @staticmethod 

168 def _batch_samples(batch: Mapping[str, Any]) -> int: 

169 """Count logical samples in one micro-batch.""" 

170 value = batch.get("input_ids") 

171 if value is None: 

172 value = batch.get("labels") 

173 shape = getattr(value, "shape", None) 

174 if shape is None or len(shape) == 0: 

175 return 0 

176 if len(shape) == 1: 

177 return 1 

178 return int(shape[0]) 

179 

180 @staticmethod 

181 def _batch_mapping(value: Any) -> Mapping[str, Any] | None: 

182 """Return metric inputs for a mapping or prepared runtime batch.""" 

183 if isinstance(value, Mapping): 

184 return value 

185 loss_count_inputs = getattr(value, "loss_count_inputs", None) 

186 if not callable(loss_count_inputs): 

187 return None 

188 metric_inputs = loss_count_inputs() 

189 return metric_inputs if isinstance(metric_inputs, Mapping) else None 

190 

191 @classmethod 

192 def _micro_batches(cls, value: Any) -> list[Mapping[str, Any]]: 

193 """Normalize callback input into a list of mapping micro-batches.""" 

194 if value is None: 

195 return [] 

196 batch = cls._batch_mapping(value) 

197 if batch is not None: 

198 return [batch] 

199 if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): 

200 batches = [] 

201 for item in value: 

202 batch = cls._batch_mapping(item) 

203 if batch is not None: 

204 batches.append(batch) 

205 return batches 

206 return [] 

207 

208 def _metric_group(self) -> Any: 

209 """Return the DP+CP process group used by loss normalization.""" 

210 dp_cp_mesh = getattr(self.trainer.mesh, "dp_cp_mesh", None) 

211 if dp_cp_mesh is None: 

212 return None 

213 return dp_cp_mesh.get_group() 

214 

215 def _reduce(self, value: Any, op: str) -> float: 

216 """Reduce one scalar metric, with a single-process no-op fallback.""" 

217 if get_world_size_safe() <= 1: 

218 return float(value) 

219 reduced = all_reduce(value, op=op, group=self._metric_group()) 

220 return float(reduced) 

221 

222 @staticmethod 

223 def _accumulate_scalar(current: Any, value: Any) -> Any: 

224 """Add one batch scalar to a running per-step total. 

225 

226 Args: 

227 current: Running total, or ``None`` before the first batch. 

228 value: Batch value to add; may be a device scalar. 

229 

230 Returns: 

231 Updated running total that never aliases the batch tensor. 

232 """ 

233 detached = value.detach() if callable(getattr(value, "detach", None)) else value 

234 if current is None: 

235 return detached.clone() if callable(getattr(detached, "clone", None)) else detached 

236 return current + detached 

237 

238 def _accumulate_batches(self, value: Any) -> None: 

239 """Accumulate batch metrics without retaining input tensor references.""" 

240 for batch in self._micro_batches(value): 

241 self._local_step_tokens = self._accumulate_scalar( 

242 self._local_step_tokens, self._batch_tokens(batch) 

243 ) 

244 geometry = self._batch_input_geometry(batch) 

245 if geometry is not None: 

246 input_tokens, seq_len_sq = geometry 

247 self._local_step_input_tokens = self._accumulate_scalar( 

248 self._local_step_input_tokens, input_tokens 

249 ) 

250 self._local_step_seq_len_sq = self._accumulate_scalar( 

251 self._local_step_seq_len_sq, seq_len_sq 

252 ) 

253 self._local_step_samples += self._batch_samples(batch) 

254 

255 def _cp_size(self) -> int: 

256 """Return the validated context-parallel size of the trainer mesh.""" 

257 cp_size = int(getattr(self.trainer.mesh, "cp_size", 1)) 

258 if cp_size < 1: 

259 raise ValueError(f"mesh.cp_size must be positive, but got {cp_size}") 

260 return cp_size 

261 

262 def _global_samples(self) -> int: 

263 """Reduce samples across DP+CP while removing CP replicas.""" 

264 cp_size = self._cp_size() 

265 reduced_samples = self._reduce(self._local_step_samples, op="sum") 

266 global_samples = reduced_samples / cp_size 

267 if not global_samples.is_integer(): 

268 raise ValueError( 

269 "Reduced sample count must be divisible by cp_size, " 

270 f"but got reduced_samples={reduced_samples} and cp_size={cp_size}" 

271 ) 

272 return int(global_samples) 

273 

274 def _current_lr(self) -> float: 

275 """Return the maximum learning rate across scheduler or optimizer groups.""" 

276 schedulers = self.trainer.lr_scheduler 

277 if schedulers is not None: 

278 scheduler_list = schedulers if isinstance(schedulers, list) else [schedulers] 

279 learning_rates = [] 

280 for scheduler in scheduler_list: 

281 for learning_rate in scheduler.get_last_lr(): 

282 learning_rates.append(float(learning_rate)) 

283 if learning_rates: 

284 return max(learning_rates) 

285 

286 optimizers = self.trainer.optimizer 

287 optimizer_list = optimizers if isinstance(optimizers, list) else [optimizers] 

288 learning_rates = [] 

289 for optimizer in optimizer_list: 

290 if optimizer is None: 

291 continue 

292 for param_group in optimizer.param_groups: 

293 learning_rates.append(float(param_group["lr"])) 

294 return max(learning_rates, default=0.0) 

295 

296 def _memory_metrics(self) -> dict[str, float]: 

297 """Collect maximum accelerator memory metrics, if available.""" 

298 if get_device_type() == "cpu": 

299 return {} 

300 device = get_torch_device() 

301 allocated = self._reduce(device.max_memory_allocated(), op="max") 

302 reserved = self._reduce(device.max_memory_reserved(), op="max") 

303 gibibyte = 1024 ** 3 

304 return { 

305 "memory/device_max_allocated_gb": allocated / gibibyte, 

306 "memory/device_max_reserved_gb": reserved / gibibyte, 

307 } 

308 

309 def state_dict(self) -> dict[str, int]: 

310 """Return cumulative metric state for future checkpoint integration.""" 

311 return { 

312 "consumed_tokens": self._consumed_tokens, 

313 "consumed_samples": self._consumed_samples, 

314 } 

315 

316 def load_state_dict(self, state_dict: dict[str, int]) -> None: 

317 """Restore cumulative metric state. 

318 

319 Args: 

320 state_dict: Mapping produced by :meth:`state_dict`. 

321 

322 Raises: 

323 ValueError: If required counters are missing or negative. 

324 """ 

325 try: 

326 consumed_tokens = int(state_dict["consumed_tokens"]) 

327 consumed_samples = int(state_dict["consumed_samples"]) 

328 except (KeyError, TypeError, ValueError) as exc: 

329 raise ValueError( 

330 "EnvironMeterCallback state must contain integer consumed_tokens and consumed_samples" 

331 ) from exc 

332 if consumed_tokens < 0 or consumed_samples < 0: 

333 raise ValueError("EnvironMeterCallback cumulative counters must be non-negative") 

334 self._consumed_tokens = consumed_tokens 

335 self._consumed_samples = consumed_samples 

336 

337 def on_step_begin( 

338 self, 

339 state: TrainerState, 

340 micro_batches: list[dict[str, Any]] | None = None, 

341 **kwargs: Any, 

342 ) -> None: 

343 """Start timing and count local input tokens and samples. 

344 

345 Args: 

346 state: Current training progress. 

347 micro_batches: Batches available before the step starts. 

348 **kwargs: Unused callback context. 

349 """ 

350 del state, kwargs 

351 self._local_step_tokens = None 

352 self._local_step_input_tokens = None 

353 self._local_step_seq_len_sq = None 

354 self._local_step_samples = 0 

355 self._step_start_time = time.perf_counter() 

356 self._local_mean_metrics.clear() 

357 self._accumulate_batches(micro_batches) 

358 

359 def on_micro_step_begin( 

360 self, 

361 state: TrainerState, 

362 micro_batch: dict[str, Any], 

363 **kwargs: Any, 

364 ) -> None: 

365 """Accumulate metrics for one prepared micro-batch. 

366 

367 Args: 

368 state: Current training progress. 

369 micro_batch: Prepared inputs and lightweight metric metadata. 

370 **kwargs: Unused callback context. 

371 """ 

372 del state, kwargs 

373 self._accumulate_batches(micro_batch) 

374 

375 def on_micro_step_end(self, state: TrainerState, loss_dict: dict[str, Any], **kwargs: Any) -> None: 

376 """Accumulate explicitly registered micro-batch means without host reads.""" 

377 del state, kwargs 

378 for name in self._microbatch_mean_metrics: 

379 value = loss_dict.get(name) 

380 if value is not None: 

381 total, count = self._local_mean_metrics.get(name, (None, 0)) 

382 self._local_mean_metrics[name] = (self._accumulate_scalar(total, value), count + 1) 

383 

384 def on_step_end( 

385 self, 

386 state: TrainerState, 

387 loss: float, 

388 loss_dict: dict[str, float] | None, 

389 grad_norm: float, 

390 **kwargs: Any, 

391 ) -> None: 

392 """Reduce and publish metrics for one completed optimizer step. 

393 

394 Args: 

395 state: Current training progress. 

396 loss: Aggregated loss for the optimizer step. 

397 loss_dict: Named loss values for the optimizer step. 

398 grad_norm: Gradient norm measured before the optimizer update. 

399 **kwargs: Unused callback context. 

400 """ 

401 del state, kwargs 

402 step_time = max(time.perf_counter() - self._step_start_time, 0.0) 

403 global_step_time = self._reduce(step_time, op="max") 

404 local_step_tokens = 0 if self._local_step_tokens is None else self._local_step_tokens 

405 global_tokens = int(self._reduce(local_step_tokens, op="sum")) 

406 global_samples = self._global_samples() 

407 cp_size = self._cp_size() 

408 local_input_tokens = ( 

409 0 if self._local_step_input_tokens is None else self._local_step_input_tokens 

410 ) 

411 local_seq_len_sq = ( 

412 0 if self._local_step_seq_len_sq is None else self._local_step_seq_len_sq 

413 ) 

414 # Context parallelism slices a sequence along its length, so each rank 

415 # only sees ``(S / cp) ** 2`` while the real attention cost is ``S ** 2``. 

416 # Summing over DP+CP therefore needs one factor of ``cp_size`` to 

417 # restore the per-sequence square. Input tokens need no correction 

418 # because the slices add back up to ``S``. 

419 global_input_tokens = self._reduce(local_input_tokens, op="sum") 

420 global_seq_len_sq = self._reduce(local_seq_len_sq, op="sum") * cp_size 

421 self._local_step_tokens = None 

422 self._local_step_input_tokens = None 

423 self._local_step_seq_len_sq = None 

424 self._local_step_samples = 0 

425 self._consumed_tokens += global_tokens 

426 self._consumed_samples += global_samples 

427 

428 train_metrics = { 

429 "training/total_loss": self._reduce(self._scalar(loss, "total_loss"), op="mean"), 

430 "training/grad_norm": self._reduce(self._scalar(grad_norm, "grad_norm"), op="mean"), 

431 "training/lr": self._current_lr(), 

432 } 

433 for name, value in sorted((loss_dict or {}).items()): 

434 if name in self._local_mean_metrics: 

435 total, count = self._local_mean_metrics[name] 

436 value = total / count 

437 metric_name = name if name.startswith("training/") else f"training/{name}" 

438 train_metrics[metric_name] = self._reduce(self._scalar(value, name), op="mean") 

439 self._local_mean_metrics.clear() 

440 

441 tokens_per_second = global_tokens / global_step_time if global_step_time > 0 else 0.0 

442 env_metrics = { 

443 **train_metrics, 

444 "performance/step_time": global_step_time, 

445 "performance/tokens_per_second": tokens_per_second, 

446 "data/step_tokens": float(global_tokens), 

447 "data/consumed_tokens": float(self._consumed_tokens), 

448 "data/step_samples": float(global_samples), 

449 "data/consumed_samples": float(self._consumed_samples), 

450 **self._memory_metrics(), 

451 } 

452 

453 training_config = getattr(getattr(self.trainer, "config", None), "training", None) 

454 model_config = getattr(self.trainer, "model_config", None) 

455 if ( 

456 getattr(training_config, "log_tflops", False) 

457 and model_config is not None 

458 and global_input_tokens > 0 

459 and global_step_time > 0 

460 ): 

461 step_flops = estimate_step_flops( 

462 model_config, global_input_tokens, global_seq_len_sq 

463 ) 

464 if step_flops is not None: 

465 env_metrics["performance/tflops_per_device"] = ( 

466 step_flops / (global_step_time * get_world_size_safe()) / 1e12 

467 ) 

468 

469 self.trainer.step_train_metrics = train_metrics 

470 self.trainer.step_env_metrics = env_metrics