Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / components / losses / chunked_cross_entropy.py: 84%

156 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-25 05:45 +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"""Memory-efficient chunked output projection and cross-entropy. 

16 

17The full ``[batch, sequence, vocabulary]`` logits tensor is never 

18materialized. Output projection, FP32 cross-entropy, and first-order 

19gradients are evaluated one local sequence chunk at a time. 

20""" 

21 

22from __future__ import annotations 

23 

24__all__ = [ 

25 "ChunkedCausalLMLoss", 

26 "ChunkedCausalLMOutput", 

27 "chunked_cross_entropy", 

28] 

29 

30from collections.abc import Mapping 

31from dataclasses import dataclass 

32from typing import Any 

33 

34# This loss is a PyTorch Trainer component and differentiates through 

35# torch.func; there is no platform-neutral autograd equivalent. PyTorch's 

36# Function metaclass supplies its public variadic dispatch contract. 

37# pylint: disable=forbidden-backend-import,not-callable,abstract-method,arguments-differ 

38import torch 

39from torch import nn 

40from torch.autograd.function import once_differentiable 

41from torch.nn import functional 

42from transformers.utils import ModelOutput 

43 

44 

45def _linear_cross_entropy_chunk( 

46 hidden_chunk: torch.Tensor, 

47 weight: torch.Tensor, 

48 target_chunk: torch.Tensor, 

49 ignore_index: int, 

50) -> torch.Tensor: 

51 """Return the FP32 summed cross-entropy for one sequence chunk.""" 

52 logits = functional.linear( 

53 hidden_chunk.reshape(-1, hidden_chunk.size(-1)), 

54 weight, 

55 ).float() 

56 return functional.cross_entropy( 

57 logits, 

58 target_chunk.reshape(-1), 

59 ignore_index=ignore_index, 

60 reduction="sum", 

61 ) 

62 

63 

64class _PrecomputedChunkLoss(torch.autograd.Function): 

65 """Precompute per-chunk gradients and replay them in outer backward.""" 

66 

67 @staticmethod 

68 def forward( 

69 ctx: Any, 

70 hidden_states: torch.Tensor, 

71 head_weight: torch.Tensor, 

72 targets: torch.Tensor, 

73 chunk_size: int, 

74 ignore_index: int, 

75 ) -> torch.Tensor: 

76 """Compute the loss sum and retain accumulated first-order gradients.""" 

77 grad_hidden = torch.empty_like(hidden_states) 

78 grad_weight = torch.zeros_like(head_weight) 

79 loss_sum = torch.zeros((), dtype=torch.float32, device=hidden_states.device) 

80 

81 grad_and_value = torch.func.grad_and_value( 

82 _linear_cross_entropy_chunk, 

83 argnums=(0, 1), 

84 ) 

85 hidden_chunks = torch.split(hidden_states, chunk_size, dim=1) 

86 target_chunks = torch.split(targets, chunk_size, dim=1) 

87 grad_hidden_chunks = torch.split(grad_hidden, chunk_size, dim=1) 

88 for hidden_chunk, target_chunk, grad_hidden_chunk in zip( 

89 hidden_chunks, target_chunks, grad_hidden_chunks, strict=True 

90 ): 

91 (chunk_grad_hidden, chunk_grad_weight), chunk_loss = grad_and_value( 

92 hidden_chunk, 

93 head_weight, 

94 target_chunk, 

95 ignore_index, 

96 ) 

97 grad_hidden_chunk.copy_(chunk_grad_hidden) 

98 grad_weight.add_(chunk_grad_weight) 

99 loss_sum.add_(chunk_loss) 

100 del chunk_grad_hidden, chunk_grad_weight, chunk_loss 

101 

102 ctx.save_for_backward(grad_hidden, grad_weight) 

103 return loss_sum 

104 

105 @staticmethod 

106 @once_differentiable 

107 def backward(ctx: Any, grad_loss_sum: torch.Tensor | None) -> tuple: 

108 """Replay private gradients with the Trainer's upstream scale.""" 

109 if grad_loss_sum is None: 

110 return None, None, None, None, None 

111 grad_hidden, grad_weight = ctx.saved_tensors 

112 return ( 

113 grad_hidden * grad_loss_sum, 

114 grad_weight * grad_loss_sum, 

115 None, 

116 None, 

117 None, 

118 ) 

119 

120 

121def _validate_chunk_options(chunk_size: int, ignore_index: int) -> None: 

122 """Validate scalar chunk-loss options.""" 

123 if isinstance(chunk_size, bool) or not isinstance(chunk_size, int): 

124 raise TypeError( 

125 f"chunk_size must be an integer, got {type(chunk_size).__name__}" 

126 ) 

127 if chunk_size <= 0: 

128 raise ValueError(f"chunk_size must be positive, got {chunk_size}") 

129 if isinstance(ignore_index, bool) or not isinstance(ignore_index, int): 

130 raise TypeError( 

131 f"ignore_index must be an integer, got {type(ignore_index).__name__}" 

132 ) 

133 

134 

135def _validate_chunk_shapes( 

136 hidden_states: torch.Tensor, 

137 targets: torch.Tensor, 

138 head_weight: torch.Tensor, 

139) -> None: 

140 """Validate tensor ranks and logical dimensions.""" 

141 if hidden_states.dim() != 3: 

142 raise ValueError( 

143 "hidden_states must have shape [batch, sequence, hidden], " 

144 f"got {tuple(hidden_states.shape)}" 

145 ) 

146 if targets.dim() != 2 or hidden_states.shape[:2] != targets.shape: 

147 raise ValueError( 

148 "targets must match hidden_states batch/sequence dimensions, " 

149 f"got hidden={tuple(hidden_states.shape)} and targets={tuple(targets.shape)}" 

150 ) 

151 if hidden_states.size(1) == 0 or hidden_states.size(2) == 0: 

152 raise ValueError( 

153 "hidden_states sequence and hidden dimensions must be non-empty" 

154 ) 

155 if head_weight.dim() != 2 or head_weight.size(1) != hidden_states.size(2): 

156 raise ValueError( 

157 "head_weight must have shape [vocabulary, hidden] matching " 

158 f"hidden_states, got hidden={tuple(hidden_states.shape)} and " 

159 f"weight={tuple(head_weight.shape)}" 

160 ) 

161 if head_weight.size(0) == 0: 

162 raise ValueError("head_weight vocabulary dimension must be non-empty") 

163 

164 

165def _validate_chunk_dtypes_and_device( 

166 hidden_states: torch.Tensor, 

167 targets: torch.Tensor, 

168 head_weight: torch.Tensor, 

169) -> None: 

170 """Validate tensor dtypes and device ownership.""" 

171 if not torch.is_floating_point(hidden_states) or not torch.is_floating_point( 

172 head_weight 

173 ): 

174 raise TypeError("hidden_states and head_weight must be floating-point tensors") 

175 if targets.dtype != torch.long: 

176 raise TypeError(f"targets must have dtype torch.long, got {targets.dtype}") 

177 if hidden_states.dtype != head_weight.dtype: 

178 raise TypeError( 

179 "hidden_states and head_weight must have the same dtype, " 

180 f"got {hidden_states.dtype} and {head_weight.dtype}" 

181 ) 

182 if not hidden_states.device == targets.device == head_weight.device: 

183 raise ValueError( 

184 "hidden_states, targets, and head_weight must be on the same device, " 

185 f"got {hidden_states.device}, {targets.device}, and {head_weight.device}" 

186 ) 

187 

188 

189def chunked_cross_entropy( 

190 hidden_states: torch.Tensor, 

191 targets: torch.Tensor, 

192 head_weight: torch.Tensor, 

193 chunk_size: int = 1024, 

194 ignore_index: int = -100, 

195) -> torch.Tensor: 

196 """Compute summed linear cross-entropy without full-sequence logits. 

197 

198 Args: 

199 hidden_states: Local hidden states in ``[B, S, H]`` layout. 

200 targets: Integer targets aligned with ``hidden_states`` in ``[B, S]``. 

201 head_weight: Full-vocabulary output weight in ``[V, H]`` layout. 

202 chunk_size: Maximum local sequence length evaluated per chunk. 

203 ignore_index: Target value excluded from the summed loss. 

204 

205 Returns: 

206 A scalar FP32 loss sum connected to ``hidden_states`` and 

207 ``head_weight``. 

208 """ 

209 _validate_chunk_options(chunk_size, ignore_index) 

210 _validate_chunk_shapes(hidden_states, targets, head_weight) 

211 _validate_chunk_dtypes_and_device(hidden_states, targets, head_weight) 

212 return _PrecomputedChunkLoss.apply( 

213 hidden_states, 

214 head_weight, 

215 targets, 

216 chunk_size, 

217 ignore_index, 

218 ) 

219 

220 

221def _get_output_value(model_output: Any, name: str) -> Any: 

222 """Read one optional value from a mapping or Transformers output.""" 

223 if isinstance(model_output, Mapping): 

224 return model_output.get(name) 

225 return getattr(model_output, name, None) 

226 

227 

228@dataclass 

229class ChunkedCausalLMOutput(ModelOutput): 

230 """Training output produced by a model-integrated Chunk Loss adapter.""" 

231 

232 loss_sum: torch.Tensor | None = None 

233 valid_token_count: torch.Tensor | None = None 

234 aux_loss: torch.Tensor | None = None 

235 aux_loss_coef: float | None = None 

236 logits: torch.Tensor | None = None 

237 past_key_values: Any | None = None 

238 hidden_states: Any | None = None 

239 attentions: Any | None = None 

240 router_logits: Any | None = None 

241 

242 

243class ChunkedCausalLMLoss(nn.Module): 

244 """Trainer-facing model-integrated Chunk Loss objective. 

245 

246 The configured model-family adapter consumes the prepared model inputs, 

247 calls :func:`chunked_cross_entropy` before a full LM-head output exists, 

248 and returns :class:`ChunkedCausalLMOutput`. This module converts the local 

249 summed CE to one local token mean; global DP/CP weighting remains owned by 

250 ``mean_global_loss``. 

251 """ 

252 

253 def __init__(self, chunk_size: int = 1024, ignore_index: int = -100) -> None: 

254 """Initialize and validate the per-rank sequence chunk options.""" 

255 super().__init__() 

256 _validate_chunk_options(chunk_size, ignore_index) 

257 self.chunk_size = chunk_size 

258 self.ignore_index = ignore_index 

259 self._model_is_bound = False 

260 

261 @staticmethod 

262 def _parallel_size(distributed_setup: Any, name: str) -> int: 

263 """Read one topology size from the shared mesh context.""" 

264 mesh = getattr(distributed_setup, "mesh_context", None) 

265 return int(getattr(mesh, name, 1)) 

266 

267 def bind_model(self, model: nn.Module, distributed_setup: Any = None) -> None: 

268 """Bind the registered model-family Chunk Loss forward adapter. 

269 

270 Args: 

271 model: Fully constructed model whose parameter structure is fixed. 

272 distributed_setup: Shared Trainer distributed setup. 

273 

274 Raises: 

275 NotImplementedError: If an unsupported TP/PP/loss-parallel mode is 

276 active. 

277 ValueError: If the model family has no Chunk Loss adapter. 

278 """ 

279 # Trainer-side token aggregation currently follows the data contract's 

280 # canonical ignore value. The model-integrated objective must use the 

281 # same value or DP/CP weighting would count a different token set. 

282 from hyper_parallel.data.constants import IGNORE_INDEX # pylint: disable=import-outside-toplevel 

283 

284 if self.ignore_index != IGNORE_INDEX: 

285 raise ValueError( 

286 "ChunkedCausalLMLoss Trainer integration requires " 

287 f"ignore_index={IGNORE_INDEX}, got {self.ignore_index}" 

288 ) 

289 

290 tp_size = self._parallel_size(distributed_setup, "tp_size") 

291 pp_size = self._parallel_size(distributed_setup, "pp_size") 

292 loss_parallel = bool( 

293 getattr(getattr(distributed_setup, "mesh_context", None), "loss_parallel", False) 

294 ) 

295 if tp_size != 1 or pp_size != 1 or loss_parallel: 

296 raise NotImplementedError( 

297 "ChunkedCausalLMLoss currently requires tp_size=1, pp_size=1, " 

298 f"and loss_parallel=false; got tp_size={tp_size}, " 

299 f"pp_size={pp_size}, loss_parallel={loss_parallel}" 

300 ) 

301 

302 # Lazy imports keep generic loss-package import independent of concrete 

303 # model families and their optional backend dependencies. 

304 from hyper_parallel.models.registry import get_model_adapter # pylint: disable=import-outside-toplevel 

305 

306 model_type = getattr(getattr(model, "config", None), "model_type", "") 

307 adapter_spec = get_model_adapter(model_type) 

308 if adapter_spec is None: 

309 architectures = getattr(model.config, "architectures", None) or () 

310 if architectures: 

311 adapter_spec = get_model_adapter(architectures[0]) 

312 provider = None if adapter_spec is None else adapter_spec.loss 

313 if provider is None: 

314 raise ValueError( 

315 "ChunkedCausalLMLoss requires a registered model-family loss " 

316 f"adapter, but model_type={model_type!r} and its architecture provide none" 

317 ) 

318 adapter = provider() 

319 bind_chunk_loss = getattr(adapter, "bind_chunk_loss", None) 

320 if not callable(bind_chunk_loss): 

321 raise TypeError( 

322 f"model_type={model_type!r} loss adapter must expose bind_chunk_loss(model)" 

323 ) 

324 bind_chunk_loss(model) 

325 self._model_is_bound = True 

326 

327 def prepare_model_inputs( 

328 self, 

329 model_inputs: Mapping[str, Any], 

330 loss_inputs: Mapping[str, Any], 

331 ) -> dict[str, Any]: 

332 """Move labels to the model-family no-full-logits protocol.""" 

333 if not self._model_is_bound: 

334 raise RuntimeError("ChunkedCausalLMLoss must be bound before model forward") 

335 targets = loss_inputs.get("shift_labels") 

336 if not isinstance(targets, torch.Tensor): 

337 raise ValueError( 

338 "ChunkedCausalLMLoss requires pre-shifted shift_labels aligned " 

339 "with every local hidden position" 

340 ) 

341 loss_mask = loss_inputs.get("loss_mask") 

342 if loss_mask is not None: 

343 if not isinstance(loss_mask, torch.Tensor) or loss_mask.shape != targets.shape: 

344 raise ValueError("loss_mask must be a Tensor with the same shape as targets") 

345 

346 prepared = dict(model_inputs) 

347 # A legacy one-dictionary caller may leave loss-only fields in 

348 # model_inputs. Replace them with the adapter's explicit protocol so 

349 # labels and masks never leak into the decoder's generic **kwargs. 

350 prepared.pop("labels", None) 

351 prepared.pop("shift_labels", None) 

352 prepared.pop("loss_mask", None) 

353 prepared.update( 

354 { 

355 "chunk_loss_targets": targets, 

356 "chunk_loss_mask": loss_mask, 

357 "chunk_loss_chunk_size": self.chunk_size, 

358 "chunk_loss_ignore_index": self.ignore_index, 

359 } 

360 ) 

361 return prepared 

362 

363 def forward( # pylint: disable=unused-argument 

364 self, 

365 *, 

366 model_output: Any, 

367 labels: torch.Tensor | None, 

368 ) -> torch.Tensor: 

369 """Normalize one adapter-produced CE sum exactly once.""" 

370 logits = _get_output_value(model_output, "logits") 

371 if logits is not None: 

372 raise ValueError( 

373 "ChunkedCausalLMLoss model adapter materialized logits; expected logits=None" 

374 ) 

375 loss_sum = _get_output_value(model_output, "loss_sum") 

376 valid_token_count = _get_output_value(model_output, "valid_token_count") 

377 if not isinstance(loss_sum, torch.Tensor) or loss_sum.numel() != 1: 

378 raise ValueError("ChunkedCausalLMLoss requires a scalar Tensor loss_sum") 

379 if not isinstance(valid_token_count, torch.Tensor) or valid_token_count.numel() != 1: 

380 raise ValueError( 

381 "ChunkedCausalLMLoss requires a scalar Tensor valid_token_count" 

382 ) 

383 

384 denominator = valid_token_count.clamp_min(1).to(loss_sum.dtype) 

385 local_loss = loss_sum / denominator 

386 aux_loss = _get_output_value(model_output, "aux_loss") 

387 if aux_loss is not None: 

388 if not isinstance(aux_loss, torch.Tensor) or aux_loss.numel() != 1: 

389 raise ValueError("ChunkedCausalLMLoss aux_loss must be a scalar Tensor") 

390 aux_loss_coef = _get_output_value(model_output, "aux_loss_coef") 

391 local_loss = local_loss + float( 

392 0.0 if aux_loss_coef is None else aux_loss_coef 

393 ) * aux_loss 

394 return torch.where( 

395 valid_token_count.ne(0), 

396 local_loss, 

397 torch.zeros_like(local_loss), 

398 )