Coverage for / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / core / activation_checkpoint / swap.py: 95%
539 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-08-04 05:18 +0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-08-04 05: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"""Swap tensor and swap manager implementation for activation checkpointing"""
16# pylint: disable=W0212
18import functools
19import threading
20import warnings
22from collections import defaultdict
23from contextlib import contextmanager
24from contextvars import ContextVar
25from typing import Any, Dict, Iterator, List, Optional, Set
27from hyper_parallel.platform import get_platform
29platform = get_platform()
31# ---------------------------------------------------------------------------
32# Module-level buffer pools — process-local, no locking needed for single-
33# stream training. Each GPU process owns its own Python interpreter, so
34# these dicts are never shared across processes.
35#
36# _CPU_PINNED_POOL: a list of available pinned CPU tensors per dtype_key.
37# Created via alloc_tensor_buffer(pin_memory=True) on the first miss; the
38# base tensor is returned here after wait_load and reused in the next
39# launch_offload, avoiding repeated cudaHostAlloc / cudaFreeHost calls.
40# ---------------------------------------------------------------------------
41_CPU_PINNED_POOL: Dict[str, List[Any]] = defaultdict(list)
42# Cap each group-swap staging allocation. 32 MiB keeps DMA chunks large
43# while avoiding one huge per-dtype staging tensor in large models.
44_GROUP_SWAP_MAX_BULK_COPY_BYTES = 32 * 1024 * 1024
47def _get_cpu_pinned_buf(dtype_key: str, total_numel: int, dtype):
48 """Pop the smallest sufficient pinned buffer from the pool, or allocate.
50 Best-fit selection minimises wasted pinned memory. When no buffer in the
51 pool is large enough, an undersized entry is discarded before allocating a
52 fresh buffer via alloc_tensor_buffer.
54 Returns the *full* buffer (capacity >= total_numel). Callers must slice
55 ``buf[:total_numel]`` for the actual copy so the returned reference can be
56 passed back to :func:`_return_cpu_pinned_buf` without any platform-specific
57 introspection.
58 """
59 pool = _CPU_PINNED_POOL[dtype_key]
60 best_i = -1
61 for i, buf in enumerate(pool):
62 if buf.numel() >= total_numel:
63 if best_i == -1 or buf.numel() < pool[best_i].numel():
64 best_i = i
65 if best_i != -1:
66 return pool.pop(best_i)
67 # No suitable buffer — discard one stale undersized entry.
68 if pool:
69 pool.pop()
70 return platform.alloc_tensor_buffer(total_numel, dtype, device='cpu', pin_memory=True)
73def _return_cpu_pinned_buf(buf):
74 """Return a full pinned CPU buffer to the pool for reuse."""
75 if buf is None:
76 return
77 _CPU_PINNED_POOL[str(buf.dtype)].append(buf)
80def _collect_device_storage_ptrs(tensors: Any) -> Set[int]:
81 """Collect device storage pointers from a nested tensor structure."""
82 storage_ptrs = set()
84 def _collect(x):
85 if isinstance(x, platform.Tensor) and str(x.device).lower() != "cpu":
86 storage_ptrs.add(x.untyped_storage().data_ptr())
87 return x
89 platform.tree_map(_collect, tensors)
90 return storage_ptrs
93class SwapTensor:
94 """A tensor that can be swapped between device and host memory asynchronously."""
95 STATE_DEVICE = "device"
96 STATE_HOST = "host"
97 STATE_D2H = "d2h"
98 STATE_H2D = "h2d"
99 STATE_NON_TENSOR = "non_tensor"
101 def __init__(self, val: Any, funcname: str, group_swap: bool = False) -> None:
102 self.val = val
103 self.funcname = funcname
104 self._keep_on_device = False
105 self._duplicate_swap = False
106 self._group_managed = False # True when this tensor is handled by SwapGroup bulk copy
107 self.group_swap = group_swap # opt-in for group copy fusion (MUST_SWAP tensors only)
108 if isinstance(val, platform.Tensor) and str(val.device).lower() != 'cpu':
109 self.ver = val._version
110 self._state = self.STATE_DEVICE
111 val_storage = val.untyped_storage()
112 self.storage_size = val_storage.size()
113 self.is_slice_tensor = self.storage_size != val.numel() * platform.get_element_size(val)
114 self.val_cpu = None
115 else:
116 self.ver = None
117 self._state = self.STATE_NON_TENSOR
118 self.val_cpu = None
119 self.is_slice_tensor = False
120 self.storage_size = 0
122 def dedup_key(self):
123 """Return a stable identity key for duplicate-swap detection."""
124 if self._state == self.STATE_NON_TENSOR:
125 return None
126 val_storage = self.val.untyped_storage()
127 return (
128 str(self.val.device),
129 val_storage.data_ptr(),
130 self.val.storage_offset(),
131 val_storage.size(),
132 tuple(self.val.stride()),
133 )
135 def mark_duplicate_swap(self) -> None:
136 """Mark this wrapper as a duplicate registration in the same swap group."""
137 self._duplicate_swap = True
139 def protect_if_aliases(self, alias_storage_ptrs: Set[int]) -> None:
140 """Keep tensors that alias externally-owned tensors on device."""
141 if self._state == self.STATE_NON_TENSOR:
142 return
143 if self.val.untyped_storage().data_ptr() in alias_storage_ptrs:
144 self._keep_on_device = True
146 def get_val(self) -> Any:
147 """Return the underlying tensor value.
149 Raises RuntimeError if the tensor is not currently in the 'device' state.
150 Non-tensor values are returned directly regardless of state.
151 """
152 if self._state == self.STATE_NON_TENSOR:
153 return self.val
154 if self._state != self.STATE_DEVICE:
155 raise RuntimeError(
156 f"Cannot call get_val(): tensor is in '{self._state}' state. "
157 f"Must be in 'device' state."
158 )
159 return self.val
161 def resize_device_storage(self):
162 """Reallocate device memory on compute stream."""
163 if self._state == self.STATE_NON_TENSOR or self._duplicate_swap:
164 return
165 if self._group_managed:
166 return
168 if self._state != self.STATE_HOST:
169 return
170 storage = self.val.untyped_storage()
171 if storage.size() == self.storage_size:
172 return
173 storage.resize_(self.storage_size)
175 def async_load(self):
176 """async load tensor from host to device"""
177 if self._state == self.STATE_NON_TENSOR or self._keep_on_device or self._duplicate_swap:
178 return
179 if self._group_managed:
180 return
182 if self._state != self.STATE_HOST:
183 warnings.warn(
184 f"[SwapTensor.async_load] Invalid state: current={self._state}, "
185 f"expected 'host'. Operation skipped."
186 )
187 return
189 if self.val_cpu is None:
190 raise ValueError("val_cpu must not be None during async_load")
191 with platform.preserve_version_counter(self.val):
192 if self.is_slice_tensor:
193 self.val.data.copy_(self.val_cpu, non_blocking=True)
194 else:
195 self.val.untyped_storage().copy_(self.val_cpu.untyped_storage(), non_blocking=True)
196 self._state = self.STATE_H2D
198 def wait_load(self):
199 """change state to device after async load is done"""
200 if self._state == self.STATE_NON_TENSOR or self._keep_on_device or self._duplicate_swap:
201 return
203 if self._state == self.STATE_DEVICE:
204 return # already loaded
205 if self._state != self.STATE_H2D:
206 warnings.warn(
207 f"[SwapTensor.wait_load] Called in invalid state: {self._state}. "
208 f"Expected 'h2d'. Skipped."
209 )
210 return
211 self._state = self.STATE_DEVICE
213 def async_offload(self):
214 """async offload tensor from device to host"""
215 if self._state == self.STATE_NON_TENSOR or self._keep_on_device or self._duplicate_swap:
216 return
217 if self._group_managed:
218 return
220 if self._state != self.STATE_DEVICE:
221 warnings.warn(
222 f"[SwapTensor.async_offload] Invalid state: current={self._state}, "
223 f"expected 'device'. Operation skipped."
224 )
225 return
227 if self.storage_size != self.val.untyped_storage().size():
228 raise RuntimeError(
229 f"There is a tensor from {self.funcname} cannot be SWAPPED! Its storage has been resized "
230 f"presize:{self.storage_size}, current size:{self.val.untyped_storage().size()}"
231 )
232 if self.ver != self.val._version:
233 raise RuntimeError(
234 f"There is a tensor from {self.funcname} cannot be SWAPPED! In-place modification happened "
235 f"preversion:{self.ver}, current version:{self.val._version}"
236 )
238 if self.val_cpu is None:
239 self.val_cpu = platform.empty_like(
240 self.val, device="cpu", pin_memory=True
241 )
242 if self.is_slice_tensor:
243 self.val_cpu.copy_(self.val, non_blocking=True)
244 else:
245 self.val_cpu.untyped_storage().copy_(self.val.untyped_storage(), non_blocking=True)
246 self._state = self.STATE_D2H
248 def wait_offload(self):
249 """wait offload to host and free device memory"""
250 if self._state == self.STATE_NON_TENSOR or self._keep_on_device or self._duplicate_swap:
251 return
253 if self._state == self.STATE_HOST:
254 return
255 if self._state != self.STATE_D2H:
256 warnings.warn(
257 f"[SwapTensor.wait_offload] Called in invalid state: {self._state}. "
258 f"Expected 'd2h'. Skipped."
259 )
260 return
261 storage = self.val.untyped_storage()
262 if storage.size() != 0:
263 storage.resize_(0)
264 self._state = self.STATE_HOST
266 @property
267 def state(self) -> str:
268 """Return the current swap state of this tensor (device, host, d2h, h2d, or non_tensor)."""
269 return self._state
271 def __repr__(self):
272 if self._state == self.STATE_NON_TENSOR:
273 return f"<SwapTensor state=non_tensor, val_type={type(self.val).__name__}>"
274 return (
275 f"<SwapTensor state={self._state}, duplicate={self._duplicate_swap}, "
276 f"device_val={'exists' if self.val is not None else 'None'}>"
277 )
280class Storage:
281 """Manage a collection of tensors for swapping operations.
283 Supports dict-like access: ``storage[key].append(item)``, ``storage.clear()``,
284 ``for batch in storage.values(): ...``.
285 """
287 def __init__(self):
288 self._data: Dict[Any, List[Any]] = defaultdict(list)
290 def __getitem__(self, key: Any) -> List[Any]:
291 return self._data[key]
293 def values(self):
294 """Return an iterable view of all stored lists."""
295 return self._data.values()
297 def clear(self):
298 """Remove all entries from the storage."""
299 self._data.clear()
301 def iter_swap_tensors(self):
302 """Iterate all SwapTensor objects stored in this storage."""
303 collected = []
305 def _collect(x):
306 if isinstance(x, SwapTensor):
307 collected.append(x)
308 return x
310 for storage_list in self.values():
311 for item in storage_list:
312 platform.tree_map(_collect, item)
313 return collected
315 def mark_duplicate_swaps(self, seen_keys) -> int:
316 """Mark tensors already registered in the same swap group as duplicates."""
317 duplicate_count = 0
318 for swap_tensor in self.iter_swap_tensors():
319 dedup_key = swap_tensor.dedup_key()
320 if dedup_key is None:
321 continue
322 if dedup_key in seen_keys:
323 swap_tensor.mark_duplicate_swap()
324 duplicate_count += 1
325 continue
326 seen_keys.add(dedup_key)
327 return duplicate_count
329 def protect_alias_storage_ptrs(self, alias_storage_ptrs: Set[int]):
330 """Avoid offloading swap entries that alias externally-owned storage."""
331 if not alias_storage_ptrs:
332 return
334 def _protect_tensor(x):
335 if isinstance(x, SwapTensor):
336 x.protect_if_aliases(alias_storage_ptrs)
337 return x
339 for storage_list in self.values():
340 for item in storage_list:
341 platform.tree_map(_protect_tensor, item)
343 def launch_load(self):
344 """launch async load for all tensors in swap storage"""
345 def _async_load(x):
346 if isinstance(x, SwapTensor):
347 x.async_load()
348 return x
350 for storage_list in self.values():
351 for item in storage_list:
352 platform.tree_map(_async_load, item)
354 def resize_device_storage(self):
355 """Resize device storage for all swap tensors (runs on compute stream)."""
356 def _resize(x):
357 if isinstance(x, SwapTensor):
358 x.resize_device_storage()
359 return x
360 for storage_list in self.values():
361 for item in storage_list:
362 platform.tree_map(_resize, item)
364 def wait_load(self):
365 """wait load for all tensors in swap storage"""
366 def _wait_load(x):
367 if isinstance(x, SwapTensor):
368 x.wait_load()
369 return x
371 for storage_list in self.values():
372 for item in storage_list:
373 platform.tree_map(_wait_load, item)
374 self.clear()
376 def wait_offload(self):
377 """wait offload for all tensors in swap storage"""
378 def _wait_offload(x):
379 if isinstance(x, SwapTensor):
380 x.wait_offload()
381 return x
383 for storage_list in self.values():
384 for item in storage_list:
385 platform.tree_map(_wait_offload, item)
387 def launch_offload(self):
388 """launch async offload for all tensors in swap storage"""
389 def _async_offload(x):
391 if isinstance(x, SwapTensor):
392 x.async_offload()
393 return x
395 for storage_list in self.values():
396 for item in storage_list:
397 platform.tree_map(_async_offload, item)
400class SwapGroup:
401 """Manager for a group of storages to coordinate swap operations.
403 Non-slice tensors within the group are packed into bounded contiguous device
404 buffers before D2H transfer, and loaded back from bounded H2D buffers.
405 Each tensor then aliases its slice of the relevant buffer via
406 ``Tensor.set_()``, avoiding per-tensor memory fragmentation.
408 Slice tensors (storage larger than logical data) fall back to the original
409 per-tensor copy path.
410 """
412 def __init__(self, group_name: str):
413 self.group_name = group_name
414 self.is_last_group: bool = False
415 self._storages: List[Storage] = []
416 self._load_event: Optional[Any] = None
417 self._offload_event: Optional[Any] = None
418 # Group-level contiguous buffers for non-slice tensors.
419 self._packed_tensor_info: List = [] # [(SwapTensor, bucket_key, element_offset), ...]
420 self._packed_buckets: Dict[str, Dict[str, Any]] = {}
421 self._group_cpu_buf = None # pinned CPU bufs; live offload→load
422 self._group_device_buf = None # temp device bufs; cleared after each phase
423 # Persistent dedup set accumulated across add() calls; avoids O(N²) rebuild.
424 # mark_duplicate_swaps mutates it in-place, so new keys are added automatically.
425 # Reset at wait_load() so stale data_ptrs don't leak into the next iteration.
426 self._seen_dedup_keys: set = set()
427 # Per-bucket SwapTensor lists built in _collect_packable_tensors and consumed
428 # in launch_offload, eliminating a redundant pass over _packed_tensor_info.
429 self._packed_by_bucket: Dict[str, List] = {}
431 def add(self, storage):
432 """Add a storage to the swap group."""
433 duplicate_count = storage.mark_duplicate_swaps(self._seen_dedup_keys)
434 if duplicate_count > 0:
435 warnings.warn(
436 f"SwapGroup '{self.group_name}' skipped {duplicate_count} duplicate tensor swap registration(s)."
437 )
438 self._storages.append(storage)
440 def protect_alias_tensors(self, tensors: Any):
441 """Protect externally-owned tensors from premature offload."""
442 alias_storage_ptrs = _collect_device_storage_ptrs(tensors)
443 if not alias_storage_ptrs:
444 return
445 for storage in self._storages:
446 storage.protect_alias_storage_ptrs(alias_storage_ptrs)
448 def _collect_packable_tensors(self) -> int:
449 """Identify tensors eligible for group packing and mark them for bulk copy.
451 A tensor is eligible only when it is contiguous, not a slice tensor,
452 not a duplicate, not sharing storage with another live swap tensor, and
453 has ``group_swap=True``. Dtype buckets are split before their staging
454 allocation would exceed ``_GROUP_SWAP_MAX_BULK_COPY_BYTES``. A packed
455 bucket with fewer than two tensors is left on the original per-tensor
456 path because it has no batch-copy benefit. Non-contiguous
457 tensors are excluded because the packing step copies storage-order
458 bytes while restore uses the original stride; those tensors fall back to
459 the per-tensor copy path.
460 Shared-storage tensors also fall back together because group packing
461 frees the original storage after packing, which would invalidate any
462 non-packed aliases such as transpose views before their own offload.
464 Side effects: marks each eligible tensor with ``_group_managed=True``
465 and ``_state=STATE_D2H``, and populates ``_packed_tensor_info`` /
466 ``_packed_buckets``.
468 Returns:
469 Total byte count of all packable tensors.
470 """
471 candidate_buckets: Dict[str, List[Dict[str, Any]]] = {}
472 packed_info: List = []
473 packed_buckets: Dict[str, Dict[str, Any]] = {}
474 packed_by_bucket: Dict[str, List] = {}
475 total_bytes = 0
477 def _try_pack(x):
478 if not isinstance(x, SwapTensor):
479 return x
480 no_pack = (not x.group_swap or x._state != SwapTensor.STATE_DEVICE or x._keep_on_device
481 or x.is_slice_tensor or x._duplicate_swap or x.storage_size >= _GROUP_SWAP_MAX_BULK_COPY_BYTES
482 or not x.val.is_contiguous())
483 if no_pack:
484 return x
485 if x.storage_size != x.val.untyped_storage().size():
486 raise RuntimeError(
487 f"There is a tensor from {x.funcname} cannot be SWAPPED! Its storage has been resized "
488 f"presize:{x.storage_size}, current size:{x.val.untyped_storage().size()}"
489 )
490 if x.ver != x.val._version:
491 raise RuntimeError(
492 f"There is a tensor from {x.funcname} cannot be SWAPPED! In-place modification happened "
493 f"preversion:{x.ver}, current version:{x.val._version}"
494 )
495 dtype_key = str(x.val.dtype)
496 dtype_buckets = candidate_buckets.setdefault(dtype_key, [])
497 if (not dtype_buckets or
498 dtype_buckets[-1]["total_bytes"] + x.storage_size > _GROUP_SWAP_MAX_BULK_COPY_BYTES):
499 dtype_buckets.append({
500 "bucket_key": f"{dtype_key}#{len(dtype_buckets)}",
501 "dtype": x.val.dtype,
502 "dtype_key": dtype_key,
503 "device": x.val.device,
504 "tensors": [],
505 "total_bytes": 0,
506 "total_numel": 0,
507 })
508 bucket = dtype_buckets[-1]
509 bucket["tensors"].append(x)
510 bucket["total_bytes"] += x.storage_size
511 bucket["total_numel"] += x.val.numel()
512 return x
514 for storage in self._storages:
515 for storage_list in storage.values():
516 for item in storage_list:
517 platform.tree_map(_try_pack, item)
519 for dtype_bucket_list in candidate_buckets.values():
520 for candidate_bucket in dtype_bucket_list:
521 tensors = candidate_bucket["tensors"]
522 if len(tensors) < 2:
523 continue
524 bucket_key = candidate_bucket["bucket_key"]
525 packed_buckets[bucket_key] = {
526 "dtype": candidate_bucket["dtype"],
527 "dtype_key": candidate_bucket["dtype_key"],
528 "device": candidate_bucket["device"],
529 "total_numel": candidate_bucket["total_numel"],
530 }
531 element_offset = 0
532 for tensor in tensors:
533 tensor._group_managed = True
534 tensor._state = SwapTensor.STATE_D2H
535 packed_info.append((tensor, bucket_key, element_offset))
536 element_offset += tensor.val.numel()
537 packed_by_bucket[bucket_key] = tensors
538 total_bytes += candidate_bucket["total_bytes"]
540 self._packed_tensor_info = packed_info
541 self._packed_buckets = packed_buckets
542 self._packed_by_bucket = packed_by_bucket
543 return total_bytes
545 def launch_offload(self, copy_stream):
546 """Launch async offload for all storages in the group.
548 Non-slice tensors are first packed into bounded contiguous device
549 buffers, then transferred to pinned CPU memory. Slice tensors are
550 offloaded individually via the existing per-tensor path.
551 """
552 total_bytes = self._collect_packable_tensors()
553 with platform.no_grad():
554 if total_bytes > 0:
555 group_device_bufs = {}
556 group_cpu_bufs = {}
557 for bucket_key, swap_tensors in self._packed_by_bucket.items():
558 group_device_bufs[bucket_key] = platform.cat(
559 [st.val.reshape(-1) for st in swap_tensors], dim=0
560 )
562 compute_event = platform.new_event()
563 compute_event.record(platform.get_current_stream())
564 self._offload_event = platform.new_event()
565 stream_context = platform.get_stream_context()
566 with platform.no_grad(), stream_context(copy_stream):
567 compute_event.wait(copy_stream)
569 if total_bytes > 0:
570 # One-shot D2H per packed bucket. MindSpore requires tensor/storage dtype consistency.
571 for bucket_key, bucket in self._packed_buckets.items():
572 dtype_key = bucket["dtype_key"]
573 numel = bucket["total_numel"]
574 cpu_buf = _get_cpu_pinned_buf(dtype_key, numel, bucket["dtype"])
575 group_cpu_bufs[bucket_key] = cpu_buf
576 cpu_buf[:numel].copy_(group_device_bufs[bucket_key], non_blocking=True)
577 self._group_device_buf = group_device_bufs
578 self._group_cpu_buf = group_cpu_bufs
580 # Slice tensors use the existing per-tensor path.
581 # Group-managed tensors are already STATE_D2H so async_offload is a no-op.
582 for storage in self._storages:
583 storage.launch_offload()
584 self._offload_event.record(copy_stream)
586 def wait_offload(self):
587 """Wait for offload to complete for all storages in the group."""
588 if self._offload_event is None:
589 raise RuntimeError(
590 f"SwapGroup '{self.group_name}' wait_offload() called before launch_offload()."
591 )
592 compute_stream = platform.get_current_stream()
593 stream_context = platform.get_stream_context()
594 with platform.no_grad(), stream_context(compute_stream):
595 self._offload_event.wait(compute_stream)
596 self._offload_event = None
597 for storage in self._storages:
598 storage.wait_offload()
599 # Release the temporary device packing buffer; _group_cpu_buf persists until launch_load.
600 self._group_device_buf = None
602 def launch_load(self, copy_stream):
603 """Prepare storage and launch async load for all storages in the group.
605 Non-slice tensors are loaded from pinned CPU memory into bounded
606 contiguous device buffers. Tensors will alias their slice of the
607 relevant buffer after ``wait_load``. Slice tensors use the existing
608 per-tensor path.
609 """
610 # Resize device storage for slice tensors only.
611 # Group-managed tensors skip resize_device_storage via _group_managed flag.
612 with platform.no_grad():
613 for storage in self._storages:
614 storage.resize_device_storage()
616 compute_event = platform.new_event()
617 compute_event.record(platform.get_current_stream())
618 self._load_event = platform.new_event()
619 stream_context = platform.get_stream_context()
620 with platform.no_grad(), stream_context(copy_stream):
621 compute_event.wait(copy_stream)
623 if self._packed_tensor_info and self._group_cpu_buf is not None:
624 group_device_bufs = {}
625 for bucket_key, bucket in self._packed_buckets.items():
626 cpu_buf = self._group_cpu_buf.get(bucket_key)
627 if cpu_buf is None:
628 continue
629 numel = bucket["total_numel"]
630 group_device_bufs[bucket_key] = platform.alloc_tensor_buffer(
631 numel, bucket["dtype"], bucket["device"]
632 )
633 # One-shot H2D per packed bucket.
634 group_device_bufs[bucket_key].copy_(cpu_buf[:numel], non_blocking=True)
635 self._group_device_buf = group_device_bufs
636 # Mirror async_load's STATE_H2D transition: H2D is in flight.
637 for st, _, _ in self._packed_tensor_info:
638 st._state = SwapTensor.STATE_H2D
640 # Slice tensors use the existing per-tensor path.
641 # Group-managed tensors skip async_load via _group_managed flag.
642 for storage in self._storages:
643 storage.launch_load() # Only copy, no resize
644 self._load_event.record(copy_stream)
646 def wait_load(self):
647 """Wait for load to complete for all storages in the group.
649 After the H2D transfer completes, each group-managed tensor is made to
650 alias its slice of the contiguous device buffer via ``Tensor.set_()``.
651 The buffer stays alive through the tensors' own storage references after
652 ``_group_device_buf`` is cleared here.
653 """
654 if self._load_event is None:
655 raise RuntimeError(
656 f"SwapGroup '{self.group_name}' wait_load() called before launch_load()."
657 )
658 compute_stream = platform.get_current_stream()
659 stream_context = platform.get_stream_context()
660 with platform.no_grad(), stream_context(compute_stream):
661 self._load_event.wait(compute_stream)
662 self._load_event = None
663 # Restore group-managed tensors: alias into the contiguous device buffer.
664 if self._group_device_buf is not None:
665 prev_key = None
666 group_storage = None
667 for st, bucket_key, element_offset in self._packed_tensor_info:
668 if bucket_key != prev_key:
669 group_device_buf = self._group_device_buf.get(bucket_key)
670 group_storage = group_device_buf.untyped_storage() if group_device_buf is not None else None
671 prev_key = bucket_key
672 if group_storage is None:
673 continue
674 with platform.preserve_version_counter(st.val):
675 st.val.set_(group_storage, element_offset, st.val.shape, st.val.stride())
676 st._state = SwapTensor.STATE_DEVICE
677 for storage in self._storages:
678 storage.wait_load()
679 self._storages.clear()
680 # Return CPU pinned buffers to the pool. By the time wait_load
681 # returns, _load_event has fired on the compute stream, which
682 # means the copy stream's H2D transfer has completed and the CPU
683 # buffer is no longer being read by the DMA engine. The next
684 # launch_offload (start of the following iteration) will pop these
685 # buffers from the pool, well after the current H2D is done.
686 if self._group_cpu_buf is not None:
687 for buf in self._group_cpu_buf.values():
688 _return_cpu_pinned_buf(buf)
689 self._group_cpu_buf = None
690 # Device buffer: the pool holds the staging reference; just drop
691 # the local reference. Tensors aliasing _group_device_buf's
692 # storage keep it alive via their own storage references until
693 # they are consumed in backward.
694 self._group_device_buf = None
695 self._packed_tensor_info = []
696 self._packed_buckets = {}
697 self._packed_by_bucket = {}
698 self._seen_dedup_keys = set()
701class SwapManager:
702 """Singleton manager for swap groups and their operations."""
703 _instance: Optional["SwapManager"] = None
704 _lock = threading.Lock()
706 def __init__(self) -> None:
707 """Initialize process-local swap groups once for the singleton."""
708 if hasattr(self, '_groups'):
709 return
710 self._groups: Dict[str, SwapGroup] = {}
711 self._current_group_name: ContextVar[str] = ContextVar(
712 "swap_current_group_name", default=""
713 )
714 self._layer_count: int = 0
715 self._copy_stream: Optional[Any] = None
717 def __new__(cls):
718 if cls._instance is None:
719 with cls._lock:
720 if cls._instance is None:
721 cls._instance = super().__new__(cls)
722 return cls._instance
724 def add_storage(self, group_name: str, storage: Storage) -> None:
725 """Add a storage to a specified swap group."""
726 self.ensure_group(group_name)
727 self._groups[group_name].add(storage)
729 def ensure_group(self, group_name: str) -> None:
730 """Create the swap group if it does not exist yet."""
731 if group_name not in self._groups:
732 self._groups[group_name] = SwapGroup(group_name)
734 def launch_offload(self, group_name: str, copy_stream=None):
735 """Launch async offload for a specified swap group."""
736 group = self._groups.get(group_name)
737 if group is None:
738 raise RuntimeError(f"Group {group_name} does not exist.")
739 if copy_stream is None:
740 copy_stream = self._get_copy_stream()
741 group.launch_offload(copy_stream)
743 def protect_alias_tensors(self, group_name: str, tensors: Any):
744 """Keep tensors that alias externally-owned tensors on device."""
745 group = self._groups.get(group_name)
746 if group is None:
747 raise RuntimeError(f"Group {group_name} does not exist.")
748 group.protect_alias_tensors(tensors)
750 def wait_offload(self, group_name: str):
751 """Wait for offload to complete for a specified swap group."""
752 group = self._groups.get(group_name)
753 if group is None:
754 raise RuntimeError(f"Group {group_name} does not exist.")
755 group.wait_offload()
757 def launch_load(self, group_name: str, copy_stream=None):
758 """Launch async load for a specified swap group."""
759 group = self._groups.get(group_name)
760 if group is None:
761 raise RuntimeError(f"Group {group_name} does not exist.")
762 if copy_stream is None:
763 copy_stream = self._get_copy_stream()
764 group.launch_load(copy_stream)
766 def wait_load(self, group_name: str):
767 """Wait for load to complete for a specified swap group."""
768 group = self._groups.get(group_name)
769 if group is None:
770 raise RuntimeError(f"Group {group_name} does not exist.")
771 group.wait_load()
773 def release_group_storage(self, group_name: str) -> None:
774 """Release storage references held by the swap group."""
775 group = self._groups.get(group_name)
776 if group is not None:
777 group._storages.clear()
779 def abort_group(self, group_name: str) -> None:
780 """Synchronize in-flight transfers and remove a failed run's group."""
781 group = self._groups.pop(group_name, None)
782 if group is None:
783 return
784 for event in (group._offload_event, group._load_event):
785 if event is not None:
786 event.synchronize()
787 group._storages.clear()
789 def get_current_group_name(self) -> str:
790 """Return the name of the currently active swap group."""
791 return self._current_group_name.get()
793 def set_current_group_name(self, group_name: str) -> None:
794 """Set the name of the currently active swap group."""
795 self._current_group_name.set(group_name)
797 def active_group_count(self) -> int:
798 """Return the number of live swap groups for lifecycle diagnostics."""
799 return len(self._groups)
801 @contextmanager
802 def group_context(self, group_name: str) -> Iterator[None]:
803 """Activate a swap group within the current execution context."""
804 token = self._current_group_name.set(group_name)
805 try:
806 yield
807 finally:
808 self._current_group_name.reset(token)
810 def is_last_group(self, group_name: Optional[str] = None) -> bool:
811 """Return whether the specified swap group is the terminal group in the chain."""
812 group_name = self.get_current_group_name() if group_name is None else group_name
813 group = self._groups.get(group_name)
814 if group is None:
815 return False
816 return group.is_last_group
818 def set_forward_prefetch_layer(self, first_layer, second_layer):
819 """
820 Configure prefetching and offloading order between two consecutive layers.
822 Usage:
823 for i in range(len(model.layers) - 1):
824 set_forward_prefetch_layer(model.layers[i], model.layers[i + 1])
826 Ensures idempotency: safe to call multiple times on the same layer pair.
827 """
828 if first_layer is second_layer:
829 warnings.warn(
830 "set_forward_prefetch_layer: "
831 "Prefetching between identical layers has no effect.",
832 UserWarning,
833 stacklevel=2,
834 )
836 def _ensure_group_name(module):
837 """Assign a unique swap group name to the module if not already assigned."""
838 if not hasattr(module, "_swap_group_name"):
839 name = f"swap_group_{self._layer_count}"
840 self._layer_count += 1
841 module._swap_group_name = name
842 module._swap_group_order = {"prev": None, "next": None}
843 return module._swap_group_name
844 first_name = _ensure_group_name(first_layer)
845 second_name = _ensure_group_name(second_layer)
847 if first_name not in self._groups:
848 self._groups[first_name] = SwapGroup(first_name)
849 if second_name not in self._groups:
850 self._groups[second_name] = SwapGroup(second_name)
852 if first_layer._swap_group_order["next"] is None:
853 first_layer._swap_group_order["next"] = second_name
854 if second_layer._swap_group_order["prev"] is None:
855 second_layer._swap_group_order["prev"] = first_name
857 self._groups[first_name].is_last_group = first_layer._swap_group_order["next"] is None
858 self._groups[second_name].is_last_group = second_layer._swap_group_order["next"] is None
860 def _forward_pre_hook(group_name, module, _): # pylint: disable=W0613
861 if getattr(module, "_swap_state", None) == "pre_backward":
862 return
863 SwapManager().set_current_group_name(group_name)
865 def _forward_hook(group_name, module, args, output): # pylint: disable=W0613
866 """
867 Forward post-hook executed immediately after forward computation
868 of the current layer finishes.
870 Execution timeline (example with 3 layers, forward order: L0 → L1 → L2):
872 Time →
873 Forward Compute Stream:
874 | Fwd L0 | post(L0) | Fwd L1 | post(L1) | Fwd L2 |
876 Copy Stream (offload):
877 | Offload L0 | - | Offload L1 |
878 ↑ ↑
879 offload at post(L0) offload at post(L1)
881 Swap rules:
882 1. After forward computation of the current layer completes:
883 - If a next layer exists, asynchronously offload the activations
884 of the current layer (launch_offload).
886 Example:
887 - At post-forward of L0, offload activations of L0.
888 - At post-forward of L1, offload activations of L1.
890 2. To limit device memory peak:
891 - If a previous layer exists, wait until its offload operation
892 has completed (wait_offload).
894 Notes:
895 - Offload operations are issued on the copy stream to overlap data transfer
896 with forward computation of subsequent layers.
897 - If the module is already in 'pre_backward' state, this hook is skipped
898 to avoid triggering offload during backward phase.
899 """
900 if getattr(module, "_swap_state", None) == "pre_backward":
901 return
902 next_name = module._swap_group_order.get('next', None)
903 if next_name:
904 SwapManager().protect_alias_tensors(group_name, output)
905 SwapManager().launch_offload(group_name)
906 prev_name = module._swap_group_order.get('prev', None)
907 if prev_name:
908 SwapManager().wait_offload(prev_name)
910 def _backward_pre_hook(group_name, module, grad_input): # pylint: disable=W0613
911 """
912 Pre-backward hook executed immediately before backward computation
913 of the current layer starts.
915 Execution timeline (example with 3 layers, backward order: L2 → L1 → L0):
917 Time →
918 Backward Compute Stream:
919 | pre(L2) | Grad L2 | pre(L1) | Grad L1 | pre(L0) | Grad L0 |
921 Copy Stream (load):
922 | Load L1 | - | Load L0 |
923 ↑ ↑
924 prefetch at pre(L2) prefetch at pre(L1)
926 Swap rules:
927 1. At the beginning of backward for the current layer:
928 - If a previous layer exists in backward order, asynchronously
929 prefetch its activations (launch_load).
931 Example:
932 - At pre-backward of L2, prefetch activations of L1.
933 - At pre-backward of L1, prefetch activations of L0.
935 2. Before starting backward computation of the current layer:
936 - Ensure that the activations of the current layer have already
937 been loaded back to device memory (wait_load).
939 Notes:
940 - Load operations are issued on the copy stream to overlap data transfer
941 with backward computation of the current layer.
942 - The swap state is marked as 'pre_backward' to prevent forward hooks
943 from issuing offload operations during backward phase.
944 """
945 module._swap_state = "pre_backward"
946 prev_name = module._swap_group_order.get('prev', None)
947 if prev_name:
948 SwapManager().launch_load(prev_name)
950 next_name = module._swap_group_order.get('next', None)
951 if next_name:
952 SwapManager().wait_load(group_name)
953 SwapManager().release_group_storage(group_name)
955 def _backward_hook(group_name, module, grad_input, grad_output): # pylint: disable=W0613
956 module._swap_state = "backward"
958 def _register_hooks_once(module, group_name):
959 hooks = [
960 ("_swap_forward_pre_hook_handle",
961 lambda h: platform.register_forward_pre_hook(module, h, prepend=True),
962 functools.partial(_forward_pre_hook, group_name)),
964 ("_swap_forward_hook_handle",
965 module.register_forward_hook,
966 functools.partial(_forward_hook, group_name)),
968 ("_swap_backward_pre_hook_handle",
969 lambda h: platform.register_full_backward_pre_hook(module, h, prepend=True),
970 functools.partial(_backward_pre_hook, group_name)),
972 ("_swap_backward_hook_handle",
973 lambda h: platform.register_full_backward_hook(module, h),
974 functools.partial(_backward_hook, group_name)),
975 ]
977 for attr_name, register_func, hook in hooks:
978 if not hasattr(module, attr_name):
979 handle = register_func(hook)
980 setattr(module, attr_name, handle)
981 # Register for both layers
982 _register_hooks_once(first_layer, first_name)
983 _register_hooks_once(second_layer, second_name)
985 def _get_copy_stream(self):
986 """Return a singleton copy stream, created on first access."""
987 if self._copy_stream is None:
988 self._copy_stream = platform.new_stream()
989 return self._copy_stream