1# Copyright 2025-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# Adapted from
16# hyper_parallel/platform/torch/activation_checkpoint/activation_swap.py
17# adapted for MindSpore Cell API.
18# ============================================================================
19"""Activation Swap Wrapper implementation for MindSpore."""
20from abc import ABC, abstractmethod
21from collections.abc import Iterator
22from typing import Optional, Callable, Any, Union
23import types
24import warnings
25import mindspore as ms
26from mindspore import Tensor
27from mindspore.common.parameter import Parameter
28from mindspore.nn import Cell
29
30
31_CKPT_WRAPPED_MODULE = "_ckpt_wrapped_module"
32
33
34def _strip_ckpt_wrapped_module_prefix(name: str) -> str:
35 """Remove the wrapper cell segment from a dotted MindSpore cell name."""
36 return ".".join(part for part in name.split(".") if part != _CKPT_WRAPPED_MODULE)
37
38
39class FuncCell(Cell):
40 """
41 Thin :class:`~mindspore.nn.Cell` adapter that wraps a plain callable.
42
43 Allows ordinary Python functions (or any callable without Cell
44 parameters) to be passed to :func:`checkpoint_wrapper` and
45 :func:`swap_wrapper` in place of a :class:`~mindspore.nn.Cell`.
46 The wrapped function is stored as ``_fn`` and invoked in
47 :meth:`construct`; the cell has no trainable parameters.
48
49 Args:
50 fn (callable): The function to wrap.
51
52 Example:
53 >>> wrapped = checkpoint_wrapper(lambda x: x * 2)
54 """
55
56 def __init__(self, fn: Callable):
57 super().__init__()
58 self._fn = fn
59
60 def construct(self, *args, **kwargs):
61 """Delegate to the wrapped function."""
62 return self._fn(*args, **kwargs)
63
64
65def _is_shared_function_callable(callable_obj: Callable) -> bool:
66 """Return True for stateless function objects commonly shared by modules."""
67 return isinstance(callable_obj, (types.FunctionType, types.BuiltinFunctionType, types.MethodType))
68
69
70def _iter_wrappable_callable_attrs(module: Cell) -> Iterator[tuple[str, Callable]]:
71 """Yield public per-instance callable attributes not registered as child cells.
72
73 Plain functions, builtins and bound methods are skipped: these are stateless
74 module-level utilities shared by reference across many modules (e.g.
75 ``self.reshape = mint.reshape`` / ``self.cast = ops.cast`` repeated in every
76 layer). They are never standalone checkpoint regions, and marking a shared
77 function's ``_is_wrapped`` flag would both mutate a global object and falsely
78 flag every sibling module that references the same function as an overlapping
79 wrap. Only per-instance callables (e.g. MindSpore ``Primitive`` objects)
80 participate in overlap tracking.
81 """
82 for attr_name, attr_value in vars(module).items():
83 if attr_name.startswith("_") or isinstance(attr_value, Cell):
84 continue
85 if _is_shared_function_callable(attr_value):
86 continue
87 if callable(attr_value):
88 yield attr_name, attr_value
89
90
91def _mark_wrapped(obj: Any) -> None:
92 try:
93 obj._is_wrapped = True # pylint: disable=W0212
94 except (AttributeError, TypeError):
95 pass
96
97
98def _get_wrapped_callable(cell: Cell) -> Optional[Callable]:
99 wrapped_module = getattr(cell, _CKPT_WRAPPED_MODULE, None)
100 if isinstance(wrapped_module, FuncCell):
101 return getattr(wrapped_module, "_fn", None)
102 if isinstance(cell, FuncCell):
103 return getattr(cell, "_fn", None)
104 return None
105
106
107def _raise_callable_already_wrapped(callable_obj: Callable) -> None:
108 warnings.warn(
109 f"Callable '{callable_obj.__class__.__name__}' is already wrapped. "
110 "Wrapping overlapping module regions is not allowed."
111 )
112
113
114def _check_callable_attr_not_wrapped(owner: Cell, attr_name: str, attr_value: Callable) -> None:
115 del owner, attr_name
116 if getattr(attr_value, '_is_wrapped', False):
117 _raise_callable_already_wrapped(attr_value)
118
119
120def _check_and_mark_callable(callable_obj: Callable) -> None:
121 if _is_shared_function_callable(callable_obj):
122 return
123 if getattr(callable_obj, '_is_wrapped', False):
124 warnings.warn(
125 f"Callable '{callable_obj.__class__.__name__}' or one of its ancestors is already wrapped. "
126 "Wrapping overlapping module regions is not allowed."
127 )
128 _mark_wrapped(callable_obj)
129
130
131def _check_and_mark_wrapped(module: Cell) -> None:
132 """Validate no wrapping overlap, then mark module and all descendants as wrapped.
133
134 Raises:
135 ValueError: If ``module`` or any of its descendants is already wrapped.
136 """
137 if getattr(module, '_is_wrapped', False):
138 warnings.warn(
139 f"Module '{module.__class__.__name__}' or one of its ancestors is already wrapped. "
140 "Wrapping overlapping module regions is not allowed."
141 )
142 for _, submodule in module.cells_and_names():
143 if submodule is module:
144 continue
145 wrapped_callable = _get_wrapped_callable(submodule)
146 if wrapped_callable is not None and _is_shared_function_callable(wrapped_callable):
147 continue
148 if getattr(submodule, '_is_wrapped', False):
149 if wrapped_callable is not None:
150 _raise_callable_already_wrapped(wrapped_callable)
151 warnings.warn(
152 f"Submodule '{getattr(submodule, '_ckpt_wrapped_module', submodule).__class__.__name__}' of "
153 f"'{module.__class__.__name__}' is already wrapped. "
154 "Wrapping overlapping module regions is not allowed."
155 )
156 for _, submodule in module.cells_and_names():
157 for attr_name, attr_value in _iter_wrappable_callable_attrs(submodule):
158 _check_callable_attr_not_wrapped(submodule, attr_name, attr_value)
159 for _, submodule in module.cells_and_names():
160 _mark_wrapped(submodule)
161 for _, attr_value in _iter_wrappable_callable_attrs(submodule):
162 _mark_wrapped(attr_value)
163
164
165class ActivationWrapper(Cell, ABC):
166 """
167 Base class for Activation Checkpoint Wrapper in MindSpore.
168
169 Wraps a :class:`mindspore.nn.Cell` and forwards attribute lookups,
170 parameter iteration, and indexing to the inner cell. Concrete
171 sub-classes must implement :meth:`construct`.
172
173 Not meant to be instantiated directly.
174 """
175
176 def __init__(self, module: Union[Cell, Callable], *, track_overlaps: bool = True) -> None:
177 """Initialize a wrapper and optionally participate in overlap tracking."""
178 if callable(module) and not isinstance(module, Cell):
179 if track_overlaps:
180 _check_and_mark_callable(module)
181 module = FuncCell(module)
182 if track_overlaps:
183 _mark_wrapped(module)
184 elif track_overlaps:
185 _check_and_mark_wrapped(module)
186 super().__init__(auto_prefix=False)
187 self._ckpt_wrapped_module = module
188 self._is_wrapped = track_overlaps
189 self._wrapped_param_names = {
190 id(param): param.name for _, param in module.parameters_and_names()
191 }
192
193 @property
194 def _wrapped_module(self) -> Cell:
195 """Return the wrapped callable normalized to a MindSpore Cell."""
196 return self._ckpt_wrapped_module
197
198 @abstractmethod
199 def construct(self, *args, **kwargs):
200 """Abstract construct method — subclasses must override."""
201 raise ValueError("Subclasses should implement construct().")
202
203 def __getattr__(self, name: str) -> Any:
204 """Forward missing attributes to the wrapped cell.
205
206 .. warning::
207 Do **not** call ``super().__getattr__(name)`` here.
208 MindSpore's ``Cell.__init__`` calls ``hasattr(self, "bprop")`` at
209 line 252 of ``cell.py`` *after* ``_cells`` is initialised as an
210 empty ``OrderedDict`` but *before* ``ActivationWrapper.__init__``
211 has registered ``_ckpt_wrapped_module`` into ``_cells``. The
212 PyTorch ``nn.Module.__init__`` is pure Python and never calls
213 ``hasattr`` on ``self``, so this issue does not arise there.
214
215 Using ``super().__getattr__`` here would raise ``AttributeError``
216 (``_ckpt_wrapped_module`` not yet in ``_cells``), the fallback
217 ``getattr(self._ckpt_wrapped_module, name)`` would access
218 ``self._ckpt_wrapped_module`` — triggering another
219 ``__getattr__("_ckpt_wrapped_module")`` — and the cycle repeats
220 as infinite recursion.
221
222 Instead we replicate ``Cell.__getattr__``'s own dict-probe logic
223 and fall through to the wrapped module only when it is already
224 registered.
225 """
226 for attr_dict in ('_params', '_buffers', '_cells', '_params_list'):
227 d = self.__dict__.get(attr_dict)
228 if d is not None and name in d:
229 return d[name]
230 cells = self.__dict__.get('_cells', {})
231 wrapped = cells.get(_CKPT_WRAPPED_MODULE)
232 if wrapped is not None:
233 return getattr(wrapped, name)
234 raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
235
236 @property
237 def unwrap_cell(self) -> Cell:
238 """Recursively return the innermost wrapped cell."""
239 return self._ckpt_wrapped_module
240
241 def __getitem__(self, key: int) -> Any:
242 """Forward indexing calls in case the wrapped cell is a SequentialCell."""
243 return self._ckpt_wrapped_module.__getitem__(key) # type: ignore[operator]
244
245 def cells_and_names(self, cells=None, name_prefix=''):
246 """
247 Return wrapped cells without exposing the wrapper storage prefix.
248
249 MindSpore registers ``_ckpt_wrapped_module`` as a real child cell, so
250 the default :meth:`Cell.cells_and_names` would expose names such as
251 ``layer._ckpt_wrapped_module.attn``. Strip that implementation detail
252 so downstream code sees the same names as it would for the unwrapped
253 model.
254 """
255 for cell_name, cell in super().cells_and_names(cells, name_prefix):
256 yield _strip_ckpt_wrapped_module_prefix(cell_name), cell
257
258 def parameters_and_names(
259 self,
260 name_prefix: str = '',
261 expand: bool = True,
262 ) -> Iterator[tuple[str, Parameter]]:
263 """
264 Override :meth:`parameters_and_names` to strip the wrapper prefix.
265
266 Removes all occurrences of ``_ckpt_wrapped_module.`` from parameter
267 names so that a checkpoint saved from this wrapper is compatible with
268 the unwrapped cell.
269
270 Args:
271 name_prefix (str): Prefix prepended to every parameter name.
272 expand (bool): Whether to recursively expand sub-cells.
273
274 Yields:
275 tuple[str, Parameter]: ``(name, parameter)`` pairs with the
276 wrapper prefix removed.
277 """
278 for param_name, param in super().parameters_and_names(name_prefix, expand):
279 yield _strip_ckpt_wrapped_module_prefix(param_name), param
280
281 def update_parameters_name(self, prefix='', recurse=True):
282 """
283 Update wrapped parameter names without collapsing existing full paths.
284
285 When a wrapper replaces an already-registered child cell, the wrapped
286 parameters usually already have globally unique names such as
287 ``0.attn.qkv.weight``. MindSpore will still call
288 ``wrapper.update_parameters_name("attn.")`` during reassignment; if we
289 blindly apply that prefix again through the wrapper view, those names
290 are rewritten to ``attn.qkv.weight`` and collide across layers.
291
292 For parameters that already contain the requested child prefix in their
293 existing full name, keep the current name unchanged. For fresh
294 standalone modules that only have local names like ``qkv.weight``,
295 synthesize the prefixed name as usual.
296 """
297 if prefix is None:
298 prefix = ''
299 for local_name, param in self._ckpt_wrapped_module.parameters_and_names(expand=recurse):
300 original_name = self._wrapped_param_names.get(id(param), param.name)
301 if prefix and (original_name.startswith(prefix) or f".{prefix}" in original_name):
302 new_name = original_name
303 elif prefix:
304 new_name = prefix + local_name
305 else:
306 new_name = local_name
307 if new_name != param.name:
308 param.is_init = False
309 param.name = new_name
310 self._wrapped_param_names[id(param)] = new_name
311
312
313def base_check_fn(tensor: Any) -> bool:
314 """
315 Basic eligibility check: returns ``True`` when *tensor* may be offloaded.
316
317 Skips:
318
319 * Non-tensor objects.
320 * :class:`~mindspore.common.parameter.Parameter` objects.
321 * Empty tensors (zero elements).
322
323 Args:
324 tensor: The value to test.
325
326 Returns:
327 bool: ``True`` if the tensor is eligible for CPU offloading.
328 """
329 if not isinstance(tensor, Tensor):
330 return False
331 if tensor.param_info is not None:
332 return False
333 if tensor.untyped_storage().size() == 0:
334 return False
335 return True
336
337
338def _normalize_device(device: str) -> str:
339 if ":" in device:
340 return device.split(":", maxsplit=1)[0]
341 return device
342
343
344class AsyncSaveOnCpu(ms.saved_tensors_hooks):
345 """
346 Context manager to offload tensors to CPU during forward pass.
347 """
348 def __init__(self, policy_fn=None, group_swap: bool = False) -> None:
349 # pylint: disable=C0415
350 from hyper_parallel.core.activation_checkpoint.activation_checkpoint import CheckpointPolicy
351 from hyper_parallel.core.activation_checkpoint.swap import Storage, SwapManager, SwapTensor
352 self.add_to_storage = False
353 self.storage = Storage()
354 self.count_idx = 0
355 self.policy_fn = policy_fn
356
357
358 # Cache per-context-manager state once to avoid per-tensor singleton lookups.
359 swap_manager = SwapManager()
360
361 def pack_to_cpu(tensor: ms.Tensor):
362 if not base_check_fn(tensor):
363 return tensor
364 if policy_fn is not None:
365 if policy_fn(tensor) == CheckpointPolicy.MUST_SAVE:
366 return tensor
367 if policy_fn(tensor) != CheckpointPolicy.MUST_SWAP:
368 raise RuntimeError(f"Swap :set an invalid policy {policy_fn(tensor)}")
369 group_name = swap_manager.get_current_group_name()
370 if not group_name:
371 return tensor
372 if not self.add_to_storage:
373 swap_manager.add_storage(group_name, self.storage)
374 self.add_to_storage = True
375 funcname = f"{group_name}::{tensor.shape}"
376 self.storage[self.count_idx].append(
377 SwapTensor(tensor, funcname, group_swap=group_swap)
378 )
379 self.count_idx += 1
380 return tensor
381
382 def unpack_from_cpu(tensor) -> ms.Tensor:
383 if self.storage is not None:
384 self.storage.clear()
385 self.storage = None
386 return tensor
387
388 super().__init__(pack_to_cpu, unpack_from_cpu)
389
390
391class SwapWrapper(ActivationWrapper):
392 """
393 MindSpore counterpart of :class:`~hyper_parallel.platform.torch
394 .activation_checkpoint.activation_swap.SwapWrapper`.
395
396 Wraps a :class:`~mindspore.nn.Cell` and applies async activation swap
397 during the forward pass via the platform's ``async_save_on_cpu`` context
398 manager. Falls back to a no-op context when that context is not yet
399 available on the current platform.
400
401 Args:
402 mod (Cell): The cell whose intermediate activations should be swapped.
403 policy_fn (callable, optional): Per-tensor swap policy; see
404 :class:`AsyncSaveOnCpu`.
405
406 Example:
407 >>> from hyper_parallel.platform.mindspore.activation_checkpoint import swap_wrapper
408 >>> model.layers[i].attn = swap_wrapper(model.layers[i].attn, policy_fn)
409 """
410
411 def __init__(
412 self,
413 mod: Union[Cell, Callable],
414 policy_fn: Optional[Callable] = None,
415 group_swap: bool = False,
416 ):
417 super().__init__(mod)
418 self.policy_fn = policy_fn
419 self.group_swap = group_swap
420
421 def construct(self, *args, **kwargs):
422 """Execute the wrapped module inside an async CPU-swap context."""
423 with AsyncSaveOnCpu(policy_fn=self.policy_fn, group_swap=self.group_swap):
424 return self._ckpt_wrapped_module(*args, **kwargs)
425
426
427def swap_wrapper(
428 module: Union[Cell, Callable],
429 policy_fn: Optional[Callable] = None,
430 group_swap: bool = False,
431) -> SwapWrapper:
432 """
433 Wrap *module* with async activation swap.
434
435 Args:
436 module (Cell or callable): The cell or plain function to wrap.
437 If a plain callable is passed it is automatically wrapped in a
438 :class:`FuncCell` before being stored.
439 policy_fn (callable, optional): Per-tensor swap policy; see
440 :class:`AsyncSaveOnCpu`.
441
442 Returns:
443 SwapWrapper: The wrapped cell with activation swap enabled.
444 """
445 return SwapWrapper(module, policy_fn, group_swap)
446
447
448def swap_tensor_wrapper(target, tag: Optional[str] = None, group_swap: bool = False):
449 """Register selected tensors into the current swap group.
450
451 This helper is intended to be used inside a forward path that already
452 participates in the existing swap scheduling managed by ``SwapManager``.
453 It preserves the input structure and returns the original tensors.
454 """
455 # pylint: disable=C0415
456 from hyper_parallel.core.activation_checkpoint.swap import Storage, SwapManager, SwapTensor
457 swap_manager = SwapManager()
458 group_name = swap_manager.get_current_group_name()
459 if not group_name:
460 warnings.warn(
461 f"Tensor {tag} cannot be swapped, for its group is unregistered."
462 )
463 return target
464 if swap_manager.is_last_group(group_name):
465 return target
466
467 storage = Storage()
468 count_idx = 0
469
470 def _apply(x):
471 nonlocal count_idx
472 if isinstance(x, Tensor) and base_check_fn(x):
473 tensor_tag = tag or f"{group_name}_swap_tensor"
474 funcname = f"{tensor_tag}::{tuple(x.shape)}"
475 storage[count_idx].append(SwapTensor(x, funcname, group_swap=group_swap))
476 count_idx += 1
477 return x
478
479 def _map(tree):
480 if isinstance(tree, dict):
481 return type(tree)((k, _map(v)) for k, v in tree.items())
482 if isinstance(tree, tuple):
483 return tuple(_map(v) for v in tree)
484 if isinstance(tree, list):
485 return [_map(v) for v in tree]
486 return _apply(tree)
487
488 wrapped = _map(target)
489 if count_idx > 0:
490 swap_manager.add_storage(group_name, storage)
491 return wrapped