Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / core / activation_memory / swap.py: 88%

671 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"""Swap tensor and swap manager implementation for activation checkpointing""" 

16# pylint: disable=W0212 

17 

18import functools 

19import threading 

20import warnings 

21 

22from collections import defaultdict 

23from contextlib import contextmanager 

24from contextvars import ContextVar 

25from typing import Any, Dict, Iterable, Iterator, List, Optional, Set 

26 

27from hyper_parallel.core.dtensor.dtensor import DTensor 

28from . import _backend 

29 

30# --------------------------------------------------------------------------- 

31# Module-level buffer pools — process-local, no locking needed for single- 

32# stream training. Each GPU process owns its own Python interpreter, so 

33# these dicts are never shared across processes. 

34# 

35# _CPU_PINNED_POOL: a list of available pinned CPU tensors per dtype_key. 

36# Created via alloc_tensor_buffer(pin_memory=True) on the first miss; the 

37# base tensor is returned here after wait_load and reused in the next 

38# launch_offload, avoiding repeated cudaHostAlloc / cudaFreeHost calls. 

39# --------------------------------------------------------------------------- 

40_CPU_PINNED_POOL: Dict[str, List[Any]] = defaultdict(list) 

41# Cap each group-swap staging allocation. 32 MiB keeps DMA chunks large 

42# while avoiding one huge per-dtype staging tensor in large models. 

43_GROUP_SWAP_MAX_BULK_COPY_BYTES = 32 * 1024 * 1024 

44 

45 

46def _get_cpu_pinned_buf(dtype_key: str, total_numel: int, dtype): 

47 """Pop the smallest sufficient pinned buffer from the pool, or allocate. 

48 

49 Best-fit selection minimises wasted pinned memory. When no buffer in the 

50 pool is large enough, an undersized entry is discarded before allocating a 

51 fresh buffer via alloc_tensor_buffer. 

52 

53 Returns the *full* buffer (capacity >= total_numel). Callers must slice 

54 ``buf[:total_numel]`` for the actual copy so the returned reference can be 

55 passed back to :func:`_return_cpu_pinned_buf` without any platform-specific 

56 introspection. 

57 """ 

58 pool = _CPU_PINNED_POOL[dtype_key] 

59 best_i = -1 

60 for i, buf in enumerate(pool): 

61 if buf.numel() >= total_numel: 

62 if best_i == -1 or buf.numel() < pool[best_i].numel(): 

63 best_i = i 

64 if best_i != -1: 

65 return pool.pop(best_i) 

66 # No suitable buffer — discard one stale undersized entry. 

67 if pool: 

68 pool.pop() 

69 return _backend.alloc_tensor_buffer(total_numel, dtype, device='cpu', pin_memory=True) 

70 

71 

72def _return_cpu_pinned_buf(buf): 

73 """Return a full pinned CPU buffer to the pool for reuse.""" 

74 if buf is None: 

75 return 

76 _CPU_PINNED_POOL[str(buf.dtype)].append(buf) 

77 

78 

79def _collect_device_storage_ptrs(tensors: Any) -> Set[int]: 

80 """Collect device storage pointers from a nested tensor structure.""" 

81 storage_ptrs = set() 

82 

83 def _collect(x): 

84 local_tensor = x.to_local() if isinstance(x, DTensor) else x 

85 if isinstance(local_tensor, _backend.Tensor) and str(local_tensor.device).lower() != "cpu": 

86 storage_ptrs.add(local_tensor.untyped_storage().data_ptr()) 

87 return x 

88 

89 _backend.tree_map(_collect, tensors) 

90 return storage_ptrs 

91 

92 

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" 

100 

101 def __init__(self, val: Any, funcname: str, group_swap: bool = False, cpu_pool=None) -> 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 self.cpu_pool = cpu_pool 

109 self._cpu_pool_buffer = None 

110 if isinstance(val, _backend.Tensor) and str(val.device).lower() != 'cpu': 

111 self.ver = val._version 

112 self._state = self.STATE_DEVICE 

113 val_storage = val.untyped_storage() 

114 self.storage_size = val_storage.size() 

115 self.is_slice_tensor = self.storage_size != val.numel() * _backend.get_element_size(val) 

116 self.val_cpu = None 

117 else: 

118 self.ver = None 

119 self._state = self.STATE_NON_TENSOR 

120 self.val_cpu = None 

121 self.is_slice_tensor = False 

122 self.storage_size = 0 

123 

124 def dedup_key(self): 

125 """Return a stable identity key for duplicate-swap detection.""" 

126 if self._state == self.STATE_NON_TENSOR: 

127 return None 

128 val_storage = self.val.untyped_storage() 

129 return ( 

130 str(self.val.device), 

131 val_storage.data_ptr(), 

132 self.val.storage_offset(), 

133 val_storage.size(), 

134 tuple(self.val.stride()), 

135 ) 

136 

137 def mark_duplicate_swap(self) -> None: 

138 """Mark this wrapper as a duplicate registration in the same swap group.""" 

139 self._duplicate_swap = True 

140 

141 def protect_if_aliases(self, alias_storage_ptrs: Set[int]) -> None: 

142 """Keep tensors that alias externally-owned tensors on device.""" 

143 if self._state == self.STATE_NON_TENSOR: 

144 return 

145 if self.val.untyped_storage().data_ptr() in alias_storage_ptrs: 

146 self._keep_on_device = True 

147 

148 def get_val(self) -> Any: 

149 """Return the underlying tensor value. 

150 

151 Raises RuntimeError if the tensor is not currently in the 'device' state. 

152 Non-tensor values are returned directly regardless of state. 

153 """ 

154 if self._state == self.STATE_NON_TENSOR: 

155 return self.val 

156 if self._state != self.STATE_DEVICE: 

157 raise RuntimeError( 

158 f"Cannot call get_val(): tensor is in '{self._state}' state. " 

159 f"Must be in 'device' state." 

160 ) 

161 return self.val 

162 

163 def resize_device_storage(self): 

164 """Reallocate device memory on compute stream.""" 

165 if self._state == self.STATE_NON_TENSOR or self._duplicate_swap: 

166 return 

167 

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) 

174 

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 

181 

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 

188 

189 if self.val_cpu is None: 

190 raise ValueError("val_cpu must not be None during async_load") 

191 with _backend.preserve_version_counter(self.val): 

192 if self.cpu_pool is not None or 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 

197 

198 def async_group_load(self, source): 

199 """Copy a packed device-buffer slice back into the original storage.""" 

200 if self._state == self.STATE_NON_TENSOR or self._keep_on_device or self._duplicate_swap: 

201 return 

202 if not self._group_managed: 

203 return 

204 if self._state != self.STATE_HOST: 

205 warnings.warn( 

206 f"[SwapTensor.async_group_load] Invalid state: current={self._state}, " 

207 f"expected 'host'. Operation skipped." 

208 ) 

209 return 

210 if self.val.untyped_storage().size() != self.storage_size: 

211 raise RuntimeError( 

212 f"Cannot load grouped tensor from {self.funcname}: device storage was not restored. " 

213 f"expected size:{self.storage_size}, current size:{self.val.untyped_storage().size()}" 

214 ) 

215 with _backend.preserve_version_counter(self.val): 

216 self.val.copy_(source.reshape(self.val.shape), non_blocking=True) 

217 self._state = self.STATE_H2D 

218 

219 def release_cpu_buffer(self, event=None): 

220 """Release an explicitly pooled host buffer exactly once.""" 

221 if self.cpu_pool is None or self._cpu_pool_buffer is None: 

222 return 

223 release_tensor = self.val_cpu if self.val_cpu is not None else self._cpu_pool_buffer 

224 self.cpu_pool.release(release_tensor, event=event) 

225 self._cpu_pool_buffer = None 

226 self.val_cpu = None 

227 

228 def wait_load(self, release_event=None): 

229 """change state to device after async load is done""" 

230 if self._state == self.STATE_NON_TENSOR or self._keep_on_device or self._duplicate_swap: 

231 return 

232 

233 if self._state == self.STATE_DEVICE: 

234 return # already loaded 

235 if self._state != self.STATE_H2D: 

236 warnings.warn( 

237 f"[SwapTensor.wait_load] Called in invalid state: {self._state}. " 

238 f"Expected 'h2d'. Skipped." 

239 ) 

240 return 

241 self._state = self.STATE_DEVICE 

242 self.release_cpu_buffer(release_event) 

243 if self.cpu_pool is None: 

244 self.val_cpu = None 

245 

246 def _copy_to_host(self): 

247 """Copy the device tensor to its host buffer, releasing the pooled buffer on failure.""" 

248 try: 

249 if self.cpu_pool is not None or self.is_slice_tensor: 

250 self.val_cpu.copy_(self.val, non_blocking=True) 

251 else: 

252 self.val_cpu.untyped_storage().copy_(self.val.untyped_storage(), non_blocking=True) 

253 except Exception as exc: 

254 if self.cpu_pool is not None and self._cpu_pool_buffer is not None: 

255 release_event = _backend.new_event() 

256 release_event.record(_backend.get_current_stream()) 

257 self.release_cpu_buffer(release_event) 

258 self.val_cpu = None 

259 copy_mode = "tensor" if self.cpu_pool is not None or self.is_slice_tensor else "storage" 

260 raise RuntimeError( 

261 "Failed to offload activation tensor from device to CPU: " 

262 f"source={self.funcname!r}, shape={tuple(self.val.shape)}, dtype={self.val.dtype}, " 

263 f"device={self.val.device}, copy_mode={copy_mode}, " 

264 f"cpu_pool={'enabled' if self.cpu_pool is not None else 'disabled'}. " 

265 f"Original error: {exc}" 

266 ) from exc 

267 

268 def async_offload(self): 

269 """async offload tensor from device to host""" 

270 if self._state == self.STATE_NON_TENSOR or self._keep_on_device or self._duplicate_swap: 

271 return 

272 if self._group_managed: 

273 return 

274 

275 if self._state != self.STATE_DEVICE: 

276 warnings.warn( 

277 f"[SwapTensor.async_offload] Invalid state: current={self._state}, " 

278 f"expected 'device'. Operation skipped." 

279 ) 

280 return 

281 

282 if self.storage_size != self.val.untyped_storage().size(): 

283 raise RuntimeError( 

284 f"There is a tensor from {self.funcname} cannot be SWAPPED! Its storage has been resized " 

285 f"presize:{self.storage_size}, current size:{self.val.untyped_storage().size()}" 

286 ) 

287 if self.ver != self.val._version: 

288 raise RuntimeError( 

289 f"There is a tensor from {self.funcname} cannot be SWAPPED! In-place modification happened " 

290 f"preversion:{self.ver}, current version:{self.val._version}" 

291 ) 

292 

293 if self.val_cpu is None: 

294 if self.cpu_pool is None: 

295 self.val_cpu = _backend.empty_like( 

296 self.val, device="cpu", pin_memory=True 

297 ) 

298 else: 

299 logical_bytes = self.val.numel() * _backend.get_element_size(self.val) 

300 self._cpu_pool_buffer = self.cpu_pool.acquire(logical_bytes) 

301 try: 

302 self.val_cpu = self._cpu_pool_buffer.view(self.val.dtype).reshape(self.val.shape) 

303 except Exception as exc: 

304 pool_buffer = self._cpu_pool_buffer 

305 self.release_cpu_buffer() 

306 raise RuntimeError( 

307 "Failed to create a typed CPU-pool view for activation tensor " 

308 f"from {self.funcname!r}: shape={tuple(self.val.shape)}, dtype={self.val.dtype}, " 

309 f"logical_bytes={logical_bytes}, pool_buffer_shape={tuple(pool_buffer.shape)}, " 

310 f"pool_buffer_dtype={pool_buffer.dtype}. Original error: {exc}" 

311 ) from exc 

312 

313 self._copy_to_host() 

314 self._state = self.STATE_D2H 

315 

316 def wait_offload(self): 

317 """wait offload to host and free device memory""" 

318 if self._state == self.STATE_NON_TENSOR or self._keep_on_device or self._duplicate_swap: 

319 return 

320 

321 if self._state == self.STATE_HOST: 

322 return 

323 if self._state != self.STATE_D2H: 

324 warnings.warn( 

325 f"[SwapTensor.wait_offload] Called in invalid state: {self._state}. " 

326 f"Expected 'd2h'. Skipped." 

327 ) 

328 return 

329 storage = self.val.untyped_storage() 

330 if storage.size() != 0: 

331 storage.resize_(0) 

332 self._state = self.STATE_HOST 

333 

334 @property 

335 def state(self) -> str: 

336 """Return the current swap state of this tensor (device, host, d2h, h2d, or non_tensor).""" 

337 return self._state 

338 

339 def __repr__(self): 

340 if self._state == self.STATE_NON_TENSOR: 

341 return f"<SwapTensor state=non_tensor, val_type={type(self.val).__name__}>" 

342 return ( 

343 f"<SwapTensor state={self._state}, duplicate={self._duplicate_swap}, " 

344 f"device_val={'exists' if self.val is not None else 'None'}>" 

345 ) 

346 

347 

348class Storage: 

349 """Manage a collection of tensors for swapping operations. 

350 

351 Supports dict-like access: ``storage[key].append(item)``, ``storage.clear()``, 

352 ``for batch in storage.values(): ...``. 

353 """ 

354 

355 def __init__(self): 

356 self._data: Dict[Any, List[Any]] = defaultdict(list) 

357 

358 def __getitem__(self, key: Any) -> List[Any]: 

359 return self._data[key] 

360 

361 def values(self): 

362 """Return an iterable view of all stored lists.""" 

363 return self._data.values() 

364 

365 def clear(self): 

366 """Remove all entries from the storage.""" 

367 self._data.clear() 

368 

369 def iter_swap_tensors(self): 

370 """Iterate all SwapTensor objects stored in this storage.""" 

371 collected = [] 

372 

373 def _collect(x): 

374 if isinstance(x, SwapTensor): 

375 collected.append(x) 

376 return x 

377 

378 for storage_list in self.values(): 

379 for item in storage_list: 

380 _backend.tree_map(_collect, item) 

381 return collected 

382 

383 def mark_duplicate_swaps(self, seen_keys) -> int: 

384 """Mark tensors already registered in the same swap group as duplicates.""" 

385 duplicate_count = 0 

386 for swap_tensor in self.iter_swap_tensors(): 

387 dedup_key = swap_tensor.dedup_key() 

388 if dedup_key is None: 

389 continue 

390 if dedup_key in seen_keys: 

391 swap_tensor.mark_duplicate_swap() 

392 duplicate_count += 1 

393 continue 

394 seen_keys.add(dedup_key) 

395 return duplicate_count 

396 

397 def protect_alias_storage_ptrs(self, alias_storage_ptrs: Set[int]): 

398 """Avoid offloading swap entries that alias externally-owned storage.""" 

399 if not alias_storage_ptrs: 

400 return 

401 

402 def _protect_tensor(x): 

403 if isinstance(x, SwapTensor): 

404 x.protect_if_aliases(alias_storage_ptrs) 

405 return x 

406 

407 for storage_list in self.values(): 

408 for item in storage_list: 

409 _backend.tree_map(_protect_tensor, item) 

410 

411 def launch_load(self): 

412 """launch async load for all tensors in swap storage""" 

413 def _async_load(x): 

414 if isinstance(x, SwapTensor): 

415 x.async_load() 

416 return x 

417 

418 for storage_list in self.values(): 

419 for item in storage_list: 

420 _backend.tree_map(_async_load, item) 

421 

422 def resize_device_storage(self): 

423 """Resize device storage for all swap tensors (runs on compute stream).""" 

424 def _resize(x): 

425 if isinstance(x, SwapTensor): 

426 x.resize_device_storage() 

427 return x 

428 for storage_list in self.values(): 

429 for item in storage_list: 

430 _backend.tree_map(_resize, item) 

431 

432 def wait_load(self, release_event=None): 

433 """wait load for all tensors in swap storage""" 

434 def _wait_load(x): 

435 if isinstance(x, SwapTensor): 

436 x.wait_load(release_event=release_event) 

437 return x 

438 

439 for storage_list in self.values(): 

440 for item in storage_list: 

441 _backend.tree_map(_wait_load, item) 

442 self.clear() 

443 

444 def release_cpu_buffers(self, event=None): 

445 """Release all explicitly pooled host buffers held by this storage.""" 

446 def _release(x): 

447 if isinstance(x, SwapTensor): 

448 x.release_cpu_buffer(event=event) 

449 return x 

450 

451 for storage_list in self.values(): 

452 for item in storage_list: 

453 _backend.tree_map(_release, item) 

454 

455 def wait_offload(self): 

456 """wait offload for all tensors in swap storage""" 

457 def _wait_offload(x): 

458 if isinstance(x, SwapTensor): 

459 x.wait_offload() 

460 return x 

461 

462 for storage_list in self.values(): 

463 for item in storage_list: 

464 _backend.tree_map(_wait_offload, item) 

465 

466 def launch_offload(self): 

467 """launch async offload for all tensors in swap storage""" 

468 def _async_offload(x): 

469 

470 if isinstance(x, SwapTensor): 

471 x.async_offload() 

472 return x 

473 

474 for storage_list in self.values(): 

475 for item in storage_list: 

476 _backend.tree_map(_async_offload, item) 

477 

478 

479class SwapGroup: 

480 """Manager for a group of storages to coordinate swap operations. 

481 

482 Non-slice tensors within the group are packed into bounded contiguous device 

483 buffers before D2H transfer, and loaded back from bounded H2D buffers. 

484 Each packed slice is copied back into its tensor's original device storage so 

485 autograd views that alias that storage remain valid during backward. 

486 

487 Slice tensors (storage larger than logical data) fall back to the original 

488 per-tensor copy path. 

489 """ 

490 

491 def __init__(self, group_name: str): 

492 self.group_name = group_name 

493 self.is_last_group: bool = False 

494 self._storages: List[Storage] = [] 

495 self._load_event: Optional[Any] = None 

496 self._offload_event: Optional[Any] = None 

497 # Group-level contiguous buffers for non-slice tensors. 

498 self._packed_tensor_info: List = [] # [(SwapTensor, bucket_key, element_offset), ...] 

499 self._packed_buckets: Dict[str, Dict[str, Any]] = {} 

500 self._group_cpu_buf = None # pinned CPU bufs; live offload→load 

501 self._group_device_buf = None # temp device bufs; cleared after each phase 

502 # Persistent dedup set accumulated across add() calls; avoids O(N²) rebuild. 

503 # mark_duplicate_swaps mutates it in-place, so new keys are added automatically. 

504 # Reset at wait_load() so stale data_ptrs don't leak into the next iteration. 

505 self._seen_dedup_keys: set = set() 

506 # Per-bucket SwapTensor lists built in _collect_packable_tensors and consumed 

507 # in launch_offload, eliminating a redundant pass over _packed_tensor_info. 

508 self._packed_by_bucket: Dict[str, List] = {} 

509 

510 def add(self, storage): 

511 """Add a storage to the swap group.""" 

512 duplicate_count = storage.mark_duplicate_swaps(self._seen_dedup_keys) 

513 if duplicate_count > 0: 

514 warnings.warn( 

515 f"SwapGroup '{self.group_name}' skipped {duplicate_count} duplicate tensor swap registration(s)." 

516 ) 

517 self._storages.append(storage) 

518 

519 def protect_alias_tensors(self, tensors: Any): 

520 """Protect externally-owned tensors from premature offload.""" 

521 alias_storage_ptrs = _collect_device_storage_ptrs(tensors) 

522 if not alias_storage_ptrs: 

523 return 

524 for storage in self._storages: 

525 storage.protect_alias_storage_ptrs(alias_storage_ptrs) 

526 

527 @staticmethod 

528 def _bucket_tensor(candidate_buckets, dtype_key, x): 

529 """Append ``x`` to its dtype bucket, opening a new bucket when full.""" 

530 dtype_buckets = candidate_buckets.setdefault(dtype_key, []) 

531 if (not dtype_buckets or 

532 dtype_buckets[-1]["total_bytes"] + x.storage_size > _GROUP_SWAP_MAX_BULK_COPY_BYTES): 

533 dtype_buckets.append({ 

534 "bucket_key": f"{dtype_key}#{len(dtype_buckets)}", 

535 "dtype": x.val.dtype, 

536 "dtype_key": str(x.val.dtype), 

537 "device": x.val.device, 

538 "tensors": [], 

539 "total_bytes": 0, 

540 "total_numel": 0, 

541 "cpu_pool": x.cpu_pool, 

542 }) 

543 bucket = dtype_buckets[-1] 

544 bucket["tensors"].append(x) 

545 bucket["total_bytes"] += x.storage_size 

546 bucket["total_numel"] += x.val.numel() 

547 return x 

548 

549 @staticmethod 

550 def _finalize_packed_buckets(candidate_buckets, packed_info, packed_buckets, packed_by_bucket) -> int: 

551 """Turn multi-tensor candidate buckets into packed buckets; return owned bytes.""" 

552 total_bytes = 0 

553 for dtype_bucket_list in candidate_buckets.values(): 

554 for candidate_bucket in dtype_bucket_list: 

555 tensors = candidate_bucket["tensors"] 

556 if len(tensors) < 2: 

557 continue 

558 bucket_key = candidate_bucket["bucket_key"] 

559 packed_buckets[bucket_key] = { 

560 "dtype": candidate_bucket["dtype"], 

561 "dtype_key": candidate_bucket["dtype_key"], 

562 "device": candidate_bucket["device"], 

563 "total_numel": candidate_bucket["total_numel"], 

564 "total_bytes": candidate_bucket["total_bytes"], 

565 "cpu_pool": candidate_bucket["cpu_pool"], 

566 } 

567 element_offset = 0 

568 for tensor in tensors: 

569 tensor._group_managed = True 

570 tensor._state = SwapTensor.STATE_D2H 

571 packed_info.append((tensor, bucket_key, element_offset)) 

572 element_offset += tensor.val.numel() 

573 packed_by_bucket[bucket_key] = tensors 

574 total_bytes += candidate_bucket["total_bytes"] 

575 return total_bytes 

576 

577 def _collect_packable_tensors(self) -> int: 

578 """Identify tensors eligible for group packing and mark them for bulk copy. 

579 

580 A tensor is eligible only when it is contiguous, not a slice tensor, 

581 not a duplicate, not sharing storage with another live swap tensor, and 

582 has ``group_swap=True``. Dtype buckets are split before their staging 

583 allocation would exceed ``_GROUP_SWAP_MAX_BULK_COPY_BYTES``. A packed 

584 bucket with fewer than two tensors is left on the original per-tensor 

585 path because it has no batch-copy benefit. Non-contiguous 

586 tensors are excluded because the packing step copies storage-order 

587 bytes while restore uses the original stride; those tensors fall back to 

588 the per-tensor copy path. 

589 Shared-storage tensors also fall back together because group packing 

590 frees the original storage after packing, which would invalidate any 

591 non-packed aliases such as transpose views before their own offload. 

592 

593 Side effects: marks each eligible tensor with ``_group_managed=True`` 

594 and ``_state=STATE_D2H``, and populates ``_packed_tensor_info`` / 

595 ``_packed_buckets``. 

596 

597 Returns: 

598 Total byte count of all packable tensors. 

599 """ 

600 candidate_buckets: Dict[str, List[Dict[str, Any]]] = {} 

601 packed_info: List = [] 

602 packed_buckets: Dict[str, Dict[str, Any]] = {} 

603 packed_by_bucket: Dict[str, List] = {} 

604 

605 def _try_pack(x): 

606 if not isinstance(x, SwapTensor): 

607 return x 

608 no_pack = (not x.group_swap or x._state != SwapTensor.STATE_DEVICE or x._keep_on_device 

609 or x.is_slice_tensor or x._duplicate_swap or x.storage_size >= _GROUP_SWAP_MAX_BULK_COPY_BYTES 

610 or not x.val.is_contiguous()) 

611 if no_pack: 

612 return x 

613 if x.storage_size != x.val.untyped_storage().size(): 

614 raise RuntimeError( 

615 f"There is a tensor from {x.funcname} cannot be SWAPPED! Its storage has been resized " 

616 f"presize:{x.storage_size}, current size:{x.val.untyped_storage().size()}" 

617 ) 

618 if x.ver != x.val._version: 

619 raise RuntimeError( 

620 f"There is a tensor from {x.funcname} cannot be SWAPPED! In-place modification happened " 

621 f"preversion:{x.ver}, current version:{x.val._version}" 

622 ) 

623 dtype_key = (str(x.val.dtype), id(x.cpu_pool)) 

624 return self._bucket_tensor(candidate_buckets, dtype_key, x) 

625 

626 for storage in self._storages: 

627 for storage_list in storage.values(): 

628 for item in storage_list: 

629 _backend.tree_map(_try_pack, item) 

630 

631 total_bytes = self._finalize_packed_buckets( 

632 candidate_buckets, packed_info, packed_buckets, packed_by_bucket 

633 ) 

634 

635 self._packed_tensor_info = packed_info 

636 self._packed_buckets = packed_buckets 

637 self._packed_by_bucket = packed_by_bucket 

638 return total_bytes 

639 

640 def _pack_device_buffers(self): 

641 """Concatenate each packed bucket's tensors into one contiguous device buffer.""" 

642 group_device_bufs = {} 

643 for bucket_key, swap_tensors in self._packed_by_bucket.items(): 

644 group_device_bufs[bucket_key] = _backend.cat( 

645 [st.val.reshape(-1) for st in swap_tensors], dim=0 

646 ) 

647 return group_device_bufs 

648 

649 @staticmethod 

650 def _protect_device_bufs(group_device_bufs, copy_stream): 

651 """Keep staging device buffers reserved for the copy stream. 

652 

653 A staging buffer is otherwise referenced only by a local, so a failure 

654 part-way through the copy loop can drop the last reference while the 

655 copy stream is still reading it -- and the caching allocator may then 

656 hand that memory to the compute stream. 

657 """ 

658 for buf in group_device_bufs.values(): 

659 device = getattr(buf, "device", None) 

660 if device is not None and device.type not in ("cpu", "meta"): 

661 buf.record_stream(copy_stream) 

662 

663 def _acquire_bucket_cpu_buf(self, bucket_key, bucket): 

664 """Acquire the pinned CPU buffer that receives one bucket's D2H copy.""" 

665 numel = bucket["total_numel"] 

666 if bucket["cpu_pool"] is None: 

667 return _get_cpu_pinned_buf(bucket["dtype_key"], numel, bucket["dtype"]) 

668 raw_buf = bucket["cpu_pool"].acquire(bucket["total_bytes"]) 

669 try: 

670 return raw_buf.view(bucket["dtype"]) 

671 except Exception as exc: 

672 bucket["cpu_pool"].release(raw_buf) 

673 raise RuntimeError( 

674 "Failed to create a typed CPU-pool view for packed activation bucket: " 

675 f"group={self.group_name!r}, bucket={bucket_key!r}, " 

676 f"requested_dtype={bucket['dtype']}, total_numel={numel}, " 

677 f"total_bytes={bucket['total_bytes']}, raw_buffer_shape={tuple(raw_buf.shape)}, " 

678 f"raw_buffer_dtype={raw_buf.dtype}. Original error: {exc}" 

679 ) from exc 

680 

681 def _release_offloaded_bucket_bufs(self, group_cpu_bufs, copy_stream): 

682 """Give back every buffer already filled before a failed D2H loop.""" 

683 release_event = _backend.new_event() 

684 release_event.record(copy_stream) 

685 for bucket_key, cpu_buf in group_cpu_bufs.items(): 

686 cpu_pool = self._packed_buckets[bucket_key]["cpu_pool"] 

687 if cpu_pool is not None: 

688 cpu_pool.release(cpu_buf, event=release_event) 

689 else: 

690 _return_cpu_pinned_buf(cpu_buf) 

691 

692 def _offload_buckets_d2h(self, group_device_bufs, copy_stream): 

693 """One-shot D2H per packed bucket.""" 

694 group_cpu_bufs = {} 

695 active_bucket_key = None 

696 try: 

697 for bucket_key, bucket in self._packed_buckets.items(): 

698 active_bucket_key = bucket_key 

699 cpu_buf = self._acquire_bucket_cpu_buf(bucket_key, bucket) 

700 group_cpu_bufs[bucket_key] = cpu_buf 

701 cpu_buf[:bucket["total_numel"]].copy_( 

702 group_device_bufs[bucket_key], non_blocking=True 

703 ) 

704 except Exception as exc: 

705 self._release_offloaded_bucket_bufs(group_cpu_bufs, copy_stream) 

706 failed_bucket = self._packed_buckets.get(active_bucket_key, {}) 

707 raise RuntimeError( 

708 "Failed to offload packed activation bucket from device to CPU: " 

709 f"group={self.group_name!r}, bucket={active_bucket_key!r}, " 

710 f"dtype={failed_bucket.get('dtype', 'unknown')}, " 

711 f"total_numel={failed_bucket.get('total_numel', 'unknown')}, " 

712 f"total_bytes={failed_bucket.get('total_bytes', 'unknown')}, " 

713 f"cpu_pool={'enabled' if failed_bucket.get('cpu_pool') is not None else 'disabled'}. " 

714 f"Original error: {exc}" 

715 ) from exc 

716 return group_cpu_bufs 

717 

718 def launch_offload(self, copy_stream): 

719 """Launch async offload for all storages in the group. 

720 

721 Non-slice tensors are first packed into bounded contiguous device 

722 buffers, then transferred to pinned CPU memory. Slice tensors are 

723 offloaded individually via the existing per-tensor path. 

724 """ 

725 total_bytes = self._collect_packable_tensors() 

726 with _backend.no_grad(): 

727 group_device_bufs = self._pack_device_buffers() if total_bytes > 0 else {} 

728 

729 compute_event = _backend.new_event() 

730 compute_event.record(_backend.get_current_stream()) 

731 self._offload_event = _backend.new_event() 

732 stream_context = _backend.get_stream_context() 

733 with _backend.no_grad(), stream_context(copy_stream): 

734 compute_event.wait(copy_stream) 

735 

736 if total_bytes > 0: 

737 # Publish and protect the staging buffers before the first async 

738 # D2H: if the copy loop fails part-way, the stack unwinding must 

739 # not drop the last reference to memory the copy stream is still 

740 # reading. 

741 self._group_device_buf = group_device_bufs 

742 self._protect_device_bufs(group_device_bufs, copy_stream) 

743 self._group_cpu_buf = self._offload_buckets_d2h(group_device_bufs, copy_stream) 

744 

745 # Slice tensors use the existing per-tensor path. 

746 # Group-managed tensors are already STATE_D2H so async_offload is a no-op. 

747 for storage in self._storages: 

748 storage.launch_offload() 

749 self._offload_event.record(copy_stream) 

750 

751 def wait_offload(self): 

752 """Wait for offload to complete for all storages in the group.""" 

753 if self._offload_event is None: 

754 raise RuntimeError( 

755 f"SwapGroup '{self.group_name}' wait_offload() called before launch_offload()." 

756 ) 

757 compute_stream = _backend.get_current_stream() 

758 stream_context = _backend.get_stream_context() 

759 with _backend.no_grad(), stream_context(compute_stream): 

760 self._offload_event.wait(compute_stream) 

761 self._offload_event = None 

762 for storage in self._storages: 

763 storage.wait_offload() 

764 # Release the temporary device packing buffer; _group_cpu_buf persists until launch_load. 

765 self._group_device_buf = None 

766 

767 def launch_load(self, copy_stream): 

768 """Prepare storage and launch async load for all storages in the group. 

769 

770 Non-slice tensors are loaded from pinned CPU memory into bounded 

771 contiguous device buffers, then copied device-to-device into their 

772 original storages. Slice tensors use the existing per-tensor path. 

773 """ 

774 # Restore original storages before scheduling copies. Keeping the same 

775 # storage object is required for autograd-saved views of packed tensors. 

776 with _backend.no_grad(): 

777 for storage in self._storages: 

778 storage.resize_device_storage() 

779 

780 compute_event = _backend.new_event() 

781 compute_event.record(_backend.get_current_stream()) 

782 self._load_event = _backend.new_event() 

783 stream_context = _backend.get_stream_context() 

784 with _backend.no_grad(), stream_context(copy_stream): 

785 compute_event.wait(copy_stream) 

786 

787 if self._packed_tensor_info and self._group_cpu_buf is not None: 

788 group_device_bufs = {} 

789 for bucket_key, bucket in self._packed_buckets.items(): 

790 cpu_buf = self._group_cpu_buf.get(bucket_key) 

791 if cpu_buf is None: 

792 continue 

793 numel = bucket["total_numel"] 

794 group_device_bufs[bucket_key] = _backend.alloc_tensor_buffer( 

795 numel, bucket["dtype"], bucket["device"] 

796 ) 

797 # Publish and protect the staging buffers before the first async 

798 # H2D, for the same reason as launch_offload. 

799 self._group_device_buf = group_device_bufs 

800 self._protect_device_bufs(group_device_bufs, copy_stream) 

801 

802 for bucket_key, bucket in self._packed_buckets.items(): 

803 group_device_buf = group_device_bufs.get(bucket_key) 

804 if group_device_buf is None: 

805 continue 

806 # One-shot H2D per packed bucket. 

807 group_device_buf.copy_( 

808 self._group_cpu_buf[bucket_key][:bucket["total_numel"]], 

809 non_blocking=True, 

810 ) 

811 

812 # Unpack with D2D copies into the original storages. Rebinding 

813 # st.val with set_() would leave existing aliases on freed storage. 

814 for st, bucket_key, element_offset in self._packed_tensor_info: 

815 group_device_buf = group_device_bufs.get(bucket_key) 

816 if group_device_buf is None: 

817 continue 

818 source = group_device_buf[element_offset:element_offset + st.val.numel()] 

819 st.async_group_load(source) 

820 

821 # Slice tensors use the existing per-tensor path. 

822 # Group-managed tensors skip async_load via _group_managed flag. 

823 for storage in self._storages: 

824 storage.launch_load() # Only copy, no resize 

825 self._load_event.record(copy_stream) 

826 

827 def release_cpu_buffers(self, event=None): 

828 """Release staging buffers immediately or defer until ``event`` completes.""" 

829 if self._group_cpu_buf is None: 

830 return 

831 for bucket_key, buf in self._group_cpu_buf.items(): 

832 bucket = self._packed_buckets.get(bucket_key) 

833 if bucket is not None and bucket["cpu_pool"] is not None: 

834 bucket["cpu_pool"].release(buf, event=event) 

835 else: 

836 _return_cpu_pinned_buf(buf) 

837 self._group_cpu_buf = None 

838 

839 def wait_load(self): 

840 """Wait for grouped H2D and D2D loads to complete.""" 

841 if self._load_event is None: 

842 raise RuntimeError( 

843 f"SwapGroup '{self.group_name}' wait_load() called before launch_load()." 

844 ) 

845 compute_stream = _backend.get_current_stream() 

846 load_event = self._load_event 

847 stream_context = _backend.get_stream_context() 

848 with _backend.no_grad(), stream_context(compute_stream): 

849 load_event.wait(compute_stream) 

850 for storage in self._storages: 

851 storage.wait_load(release_event=load_event) 

852 self._load_event = None 

853 self._storages.clear() 

854 # Keep explicit-pool buffers pending until the copy event completes; 

855 # legacy buffers retain their existing immediate-reuse behavior. 

856 self.release_cpu_buffers(event=load_event) 

857 # Device buffer: the pool holds the staging reference; just drop 

858 # the local reference. Tensors aliasing _group_device_buf's 

859 # storage keep it alive via their own storage references until 

860 # they are consumed in backward. 

861 self._group_device_buf = None 

862 self._packed_tensor_info = [] 

863 self._packed_buckets = {} 

864 self._packed_by_bucket = {} 

865 self._seen_dedup_keys = set() 

866 

867 

868class SwapManager: 

869 """Singleton manager for swap groups and their operations.""" 

870 _instance: Optional["SwapManager"] = None 

871 _lock = threading.Lock() 

872 _FORWARD_PREFETCH_HOOK_HANDLE_ATTRS = ( 

873 "_swap_forward_pre_hook_handle", 

874 "_swap_forward_hook_handle", 

875 "_swap_backward_pre_hook_handle", 

876 "_swap_backward_hook_handle", 

877 ) 

878 

879 def __init__(self) -> None: 

880 """Initialize process-local swap groups once for the singleton.""" 

881 if hasattr(self, '_groups'): 

882 return 

883 self._groups: Dict[str, SwapGroup] = {} 

884 self._current_group_name: ContextVar[str] = ContextVar( 

885 "swap_current_group_name", default="" 

886 ) 

887 self._layer_count: int = 0 

888 self._copy_stream: Optional[Any] = None 

889 self._missing_group_warned: bool = False 

890 

891 def __new__(cls): 

892 if cls._instance is None: 

893 with cls._lock: 

894 if cls._instance is None: 

895 cls._instance = super().__new__(cls) 

896 return cls._instance 

897 

898 def add_storage(self, group_name: str, storage: Storage) -> None: 

899 """Add a storage to a specified swap group.""" 

900 self.ensure_group(group_name) 

901 self._groups[group_name].add(storage) 

902 

903 def ensure_group(self, group_name: str) -> None: 

904 """Create the swap group if it does not exist yet.""" 

905 if group_name not in self._groups: 

906 self._groups[group_name] = SwapGroup(group_name) 

907 

908 def launch_offload(self, group_name: str, copy_stream=None): 

909 """Launch async offload for a specified swap group.""" 

910 group = self._groups.get(group_name) 

911 if group is None: 

912 raise RuntimeError(f"Group {group_name} does not exist.") 

913 if copy_stream is None: 

914 copy_stream = self._get_copy_stream() 

915 group.launch_offload(copy_stream) 

916 

917 def protect_alias_tensors(self, group_name: str, tensors: Any): 

918 """Keep tensors that alias externally-owned tensors on device.""" 

919 group = self._groups.get(group_name) 

920 if group is None: 

921 raise RuntimeError(f"Group {group_name} does not exist.") 

922 group.protect_alias_tensors(tensors) 

923 

924 def wait_offload(self, group_name: str): 

925 """Wait for offload to complete for a specified swap group.""" 

926 group = self._groups.get(group_name) 

927 if group is None: 

928 raise RuntimeError(f"Group {group_name} does not exist.") 

929 group.wait_offload() 

930 

931 def launch_load(self, group_name: str, copy_stream=None): 

932 """Launch async load for a specified swap group.""" 

933 group = self._groups.get(group_name) 

934 if group is None: 

935 raise RuntimeError(f"Group {group_name} does not exist.") 

936 if copy_stream is None: 

937 copy_stream = self._get_copy_stream() 

938 group.launch_load(copy_stream) 

939 

940 def wait_load(self, group_name: str): 

941 """Wait for load to complete for a specified swap group.""" 

942 group = self._groups.get(group_name) 

943 if group is None: 

944 raise RuntimeError(f"Group {group_name} does not exist.") 

945 group.wait_load() 

946 

947 def release_group_storage(self, group_name: str) -> None: 

948 """Release storage references held by the swap group.""" 

949 group = self._groups.get(group_name) 

950 if group is not None: 

951 group._storages.clear() 

952 

953 def abort_group(self, group_name: str) -> None: 

954 """Synchronize in-flight transfers and remove a failed run's group.""" 

955 group = self._groups.pop(group_name, None) 

956 if group is None: 

957 return 

958 for event in (group._offload_event, group._load_event): 

959 if event is not None: 

960 event.synchronize() 

961 for storage in group._storages: 

962 storage.release_cpu_buffers() 

963 group.release_cpu_buffers() 

964 group._storages.clear() 

965 

966 def get_current_group_name(self) -> str: 

967 """Return the name of the currently active swap group.""" 

968 return self._current_group_name.get() 

969 

970 def set_current_group_name(self, group_name: str) -> None: 

971 """Set the name of the currently active swap group.""" 

972 self._current_group_name.set(group_name) 

973 

974 def warn_missing_group_once(self) -> None: 

975 """Warn once per process when ``MUST_SWAP`` runs outside any swap group. 

976 

977 Activation swap only serves pipeline chunks the schedule marked as 

978 swappable; every other region keeps its activations on device. A single 

979 warning keeps that fallback discoverable without repeating for every 

980 dispatched operator. 

981 """ 

982 if self._missing_group_warned: 

983 return 

984 self._missing_group_warned = True 

985 warnings.warn( 

986 "Activation swap requested MUST_SWAP outside an active swap group; " 

987 "the activations were kept on device instead of being offloaded.", 

988 UserWarning, 

989 stacklevel=2, 

990 ) 

991 

992 def active_group_count(self) -> int: 

993 """Return the number of live swap groups for lifecycle diagnostics.""" 

994 return len(self._groups) 

995 

996 @contextmanager 

997 def group_context(self, group_name: str) -> Iterator[None]: 

998 """Activate a swap group within the current execution context.""" 

999 token = self._current_group_name.set(group_name) 

1000 try: 

1001 yield 

1002 finally: 

1003 self._current_group_name.reset(token) 

1004 

1005 def is_last_group(self, group_name: Optional[str] = None) -> bool: 

1006 """Return whether the specified swap group is the terminal group in the chain.""" 

1007 group_name = self.get_current_group_name() if group_name is None else group_name 

1008 group = self._groups.get(group_name) 

1009 if group is None: 

1010 return False 

1011 return group.is_last_group 

1012 

1013 def unregister_forward_prefetch_hooks(self, module: Any) -> int: 

1014 """Remove hooks installed by :meth:`set_forward_prefetch_layer`. 

1015 

1016 Args: 

1017 module: Module whose layer-level swap hooks should be removed. 

1018 

1019 Returns: 

1020 Number of removed hook handles. 

1021 """ 

1022 removed_count = 0 

1023 for attr_name in self._FORWARD_PREFETCH_HOOK_HANDLE_ATTRS: 

1024 if not hasattr(module, attr_name): 

1025 continue 

1026 handle = getattr(module, attr_name) 

1027 if handle is not None: 

1028 handle.remove() 

1029 delattr(module, attr_name) 

1030 removed_count += 1 

1031 return removed_count 

1032 

1033 def unregister_forward_prefetch_layer(self, module: Any) -> int: 

1034 """Tear down prefetch wiring registered on ``module`` and its group. 

1035 

1036 Removes the four forward/backward swap hooks installed on ``module`` 

1037 by :meth:`set_forward_prefetch_layer` and drops its swap group from the 

1038 process-wide singleton. Mirrors the reverse of :meth:`set_forward_prefetch_layer` 

1039 so models destroyed in a long-lived process do not leave stale ``SwapGroup`` 

1040 entries or hook handles behind (see the swap-inputs path in 

1041 ``distributed/activation_checkpoint.py``, which installs these hooks but 

1042 previously had no teardown). 

1043 

1044 Args: 

1045 module: A layer that participated in a prefetch chain. 

1046 

1047 Returns: 

1048 Number of removed hook handles. 

1049 """ 

1050 removed_count = self.unregister_forward_prefetch_hooks(module) 

1051 group_name = getattr(module, "_swap_group_name", None) 

1052 if group_name is not None: 

1053 self.abort_group(group_name) 

1054 delattr(module, "_swap_group_name") 

1055 if hasattr(module, "_swap_group_order"): 

1056 delattr(module, "_swap_group_order") 

1057 # Also drop the swap state so a module re-registered through 

1058 # set_forward_prefetch_layer does not inherit a stale "pre_backward" 

1059 # flag that would short-circuit the new forward pre-hook. 

1060 if hasattr(module, "_swap_state"): 

1061 delattr(module, "_swap_state") 

1062 return removed_count 

1063 

1064 def set_forward_prefetch_layer(self, first_layer, second_layer): 

1065 """ 

1066 Configure prefetching and offloading order between two consecutive layers. 

1067 

1068 Usage: 

1069 for i in range(len(model.layers) - 1): 

1070 set_forward_prefetch_layer(model.layers[i], model.layers[i + 1]) 

1071 

1072 Ensures idempotency: safe to call multiple times on the same layer pair. 

1073 """ 

1074 if first_layer is second_layer: 

1075 warnings.warn( 

1076 "set_forward_prefetch_layer: " 

1077 "Prefetching between identical layers has no effect.", 

1078 UserWarning, 

1079 stacklevel=2, 

1080 ) 

1081 

1082 def _ensure_group_name(module): 

1083 """Assign a unique swap group name to the module if not already assigned.""" 

1084 if not hasattr(module, "_swap_group_name"): 

1085 name = f"swap_group_{self._layer_count}" 

1086 self._layer_count += 1 

1087 module._swap_group_name = name 

1088 module._swap_group_order = {"prev": None, "next": None} 

1089 return module._swap_group_name 

1090 first_name = _ensure_group_name(first_layer) 

1091 second_name = _ensure_group_name(second_layer) 

1092 

1093 if first_name not in self._groups: 

1094 self._groups[first_name] = SwapGroup(first_name) 

1095 if second_name not in self._groups: 

1096 self._groups[second_name] = SwapGroup(second_name) 

1097 

1098 if first_layer._swap_group_order["next"] is None: 

1099 first_layer._swap_group_order["next"] = second_name 

1100 if second_layer._swap_group_order["prev"] is None: 

1101 second_layer._swap_group_order["prev"] = first_name 

1102 

1103 self._groups[first_name].is_last_group = first_layer._swap_group_order["next"] is None 

1104 self._groups[second_name].is_last_group = second_layer._swap_group_order["next"] is None 

1105 

1106 def _forward_pre_hook(group_name, module, _): # pylint: disable=W0613 

1107 if getattr(module, "_swap_state", None) == "pre_backward": 

1108 return 

1109 SwapManager().set_current_group_name(group_name) 

1110 

1111 def _forward_hook(group_name, module, args, output): # pylint: disable=W0613 

1112 """ 

1113 Forward post-hook executed immediately after forward computation 

1114 of the current layer finishes. 

1115 

1116 Execution timeline (example with 3 layers, forward order: L0 → L1 → L2): 

1117 

1118 Time → 

1119 Forward Compute Stream: 

1120 | Fwd L0 | post(L0) | Fwd L1 | post(L1) | Fwd L2 | 

1121 

1122 Copy Stream (offload): 

1123 | Offload L0 | - | Offload L1 | 

1124 ↑ ↑ 

1125 offload at post(L0) offload at post(L1) 

1126 

1127 Swap rules: 

1128 1. After forward computation of the current layer completes: 

1129 - If a next layer exists, asynchronously offload the activations 

1130 of the current layer (launch_offload). 

1131 

1132 Example: 

1133 - At post-forward of L0, offload activations of L0. 

1134 - At post-forward of L1, offload activations of L1. 

1135 

1136 2. To limit device memory peak: 

1137 - If a previous layer exists, wait until its offload operation 

1138 has completed (wait_offload). 

1139 

1140 Notes: 

1141 - Offload operations are issued on the copy stream to overlap data transfer 

1142 with forward computation of subsequent layers. 

1143 - If the module is already in 'pre_backward' state, this hook is skipped 

1144 to avoid triggering offload during backward phase. 

1145 """ 

1146 if getattr(module, "_swap_state", None) == "pre_backward": 

1147 return 

1148 next_name = module._swap_group_order.get('next', None) 

1149 if next_name: 

1150 SwapManager().protect_alias_tensors(group_name, output) 

1151 SwapManager().launch_offload(group_name) 

1152 prev_name = module._swap_group_order.get('prev', None) 

1153 if prev_name: 

1154 SwapManager().wait_offload(prev_name) 

1155 

1156 def _backward_pre_hook(group_name, module, grad_input): # pylint: disable=W0613 

1157 """ 

1158 Pre-backward hook executed immediately before backward computation 

1159 of the current layer starts. 

1160 

1161 Execution timeline (example with 3 layers, backward order: L2 → L1 → L0): 

1162 

1163 Time → 

1164 Backward Compute Stream: 

1165 | pre(L2) | Grad L2 | pre(L1) | Grad L1 | pre(L0) | Grad L0 | 

1166 

1167 Copy Stream (load): 

1168 | Load L1 | - | Load L0 | 

1169 ↑ ↑ 

1170 prefetch at pre(L2) prefetch at pre(L1) 

1171 

1172 Swap rules: 

1173 1. At the beginning of backward for the current layer: 

1174 - If a previous layer exists in backward order, asynchronously 

1175 prefetch its activations (launch_load). 

1176 

1177 Example: 

1178 - At pre-backward of L2, prefetch activations of L1. 

1179 - At pre-backward of L1, prefetch activations of L0. 

1180 

1181 2. Before starting backward computation of the current layer: 

1182 - Ensure that the activations of the current layer have already 

1183 been loaded back to device memory (wait_load). 

1184 

1185 Notes: 

1186 - Load operations are issued on the copy stream to overlap data transfer 

1187 with backward computation of the current layer. 

1188 - The swap state is marked as 'pre_backward' to prevent forward hooks 

1189 from issuing offload operations during backward phase. 

1190 """ 

1191 module._swap_state = "pre_backward" 

1192 prev_name = module._swap_group_order.get('prev', None) 

1193 if prev_name: 

1194 SwapManager().launch_load(prev_name) 

1195 

1196 next_name = module._swap_group_order.get('next', None) 

1197 if next_name: 

1198 SwapManager().wait_load(group_name) 

1199 SwapManager().release_group_storage(group_name) 

1200 

1201 def _backward_hook(group_name, module, grad_input, grad_output): # pylint: disable=W0613 

1202 module._swap_state = "backward" 

1203 

1204 def _register_hooks_once(module, group_name): 

1205 hooks = [ 

1206 ("_swap_forward_pre_hook_handle", 

1207 lambda h: _backend.register_forward_pre_hook(module, h, prepend=True), 

1208 functools.partial(_forward_pre_hook, group_name)), 

1209 

1210 ("_swap_forward_hook_handle", 

1211 module.register_forward_hook, 

1212 functools.partial(_forward_hook, group_name)), 

1213 

1214 ("_swap_backward_pre_hook_handle", 

1215 lambda h: _backend.register_full_backward_pre_hook(module, h, prepend=True), 

1216 functools.partial(_backward_pre_hook, group_name)), 

1217 

1218 ("_swap_backward_hook_handle", 

1219 lambda h: _backend.register_full_backward_hook(module, h), 

1220 functools.partial(_backward_hook, group_name)), 

1221 ] 

1222 

1223 for attr_name, register_func, hook in hooks: 

1224 if not hasattr(module, attr_name): 

1225 handle = register_func(hook) 

1226 setattr(module, attr_name, handle) 

1227 # Register for both layers 

1228 _register_hooks_once(first_layer, first_name) 

1229 _register_hooks_once(second_layer, second_name) 

1230 

1231 def _get_copy_stream(self): 

1232 """Return a singleton copy stream, created on first access.""" 

1233 if self._copy_stream is None: 

1234 self._copy_stream = _backend.new_stream() 

1235 return self._copy_stream 

1236 

1237 

1238def _teardown_wired_swap_layers(wired_modules: Iterable[Any]) -> None: 

1239 """Release swap groups and prefetch hooks for an iterable of wired layers. 

1240 

1241 Teardown for layers already registered through 

1242 :meth:`SwapManager.set_forward_prefetch_layer` (the swap-inputs paths in 

1243 ``distributed/activation_checkpoint.py`` and ``distributed/attention_swap.py``). 

1244 Those paths register the modules in the process-wide singleton and install 

1245 hook handles, but never otherwise tear them down, so a long-lived process 

1246 that rebuilds or discards models leaks ``SwapGroup`` entries and hook 

1247 handles. This is called from each path's life-cycle finalizer with the wired 

1248 modules held directly, because the model container is unreachable at that 

1249 point and its module tree can no longer be traversed. Idempotent: a module 

1250 with no ``_swap_group_name`` (already released) is a no-op. 

1251 

1252 Args: 

1253 wired_modules: Layers that participated in a swap prefetch chain. 

1254 """ 

1255 manager = SwapManager() 

1256 for module in wired_modules: 

1257 manager.unregister_forward_prefetch_layer(module)