Coverage for / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / compile / passes / activation_checkpoint.py: 92%
349 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-11-15 07:18 +0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-11-15 07:18 +0800
1# Copyright 2026 Huawei Technologies Co., Ltd
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14# ============================================================================
15"""Activation Checkpoint Pass - static-graph activation checkpointing.
17FX-level activation recomputation for the joint forward+backward graph,
18following torchtitan graph_trainer's ``tag_with_memory_policy_pass`` +
19``selective_activation_remat_pass`` design while reusing the dynamic-graph
20configuration vocabulary (``ActivationCheckpointConfig``: ``mode`` =
21off/full/selective, layer selection, model-adapter safe regions):
231. Tag: forward ``call_function`` nodes inside the checkpoint scope get a
24 ``CheckpointPolicy`` in ``node.meta["recompute"]``. The scope is the
25 discovered transformer blocks (the same HuggingFace-marker discovery the
26 eager path requires), narrowed by the optional layer selectors and the
27 model adapter's safe regions. Nodes outside the blocks (embedding, head,
28 the ``train_fn`` loss chain) and compute/communication intensive
29 operators under ``selective`` stay saved; everything else is a recompute
30 candidate (``full`` forces every scoped node). Structural exceptions are
31 always saved: RNG ops (the graph cannot replay random state), in-place
32 mutations with their consumers and the readers of every tensor sharing
33 the mutated storage (including view aliases), SymInt scalars.
342. Remat: every tagged node that the backward half consumes (transitively
35 through other tagged nodes) is duplicated in front of its earliest
36 backward consumer and the backward consumers are redirected to the
37 duplicate. A forward original with no remaining readers is erased; one
38 still read by forward code stays, and its backward readers now rebuild
39 the value during backward. Either way the activation's forward lifetime
40 ends at its last forward use instead of spanning the whole step. A
41 cross-unit forward consumer forces the producing node to stay saved
42 (recreating it would only waste compute), which keeps each recompute
43 chain inside one block.
45The backward half is identified by ``meta["autograd_backward"]``, which the
46joint tracer guarantees on every supported torch (engine tag on patched
47torch, structural reconstruction otherwise -- see
48``tracer/graph_tracer.py``).
50Ordering contract: the pass must run BEFORE ``FSDPPass``. Recomputed
51duplicates are ordinary forward ops at tag time, so ``FSDPPass`` inserts
52parameter all-gathers and the backward re-gathers around them correctly
53(duplicates are tagged ``autograd_backward`` and count as backward
54consumers). Running AC after FSDP would make recompute read sharded
55placeholders. ``PpPass`` runs later and inherits the rewrite value-level.
57Operator policy sets and transformer-block discovery are shared with the
58eager path (``hyper_parallel.distributed.activation_checkpoint``), so a
59model sees the same save/recompute vocabulary in both modes. RNG ops are
60saved in every mode (a graph cannot replay random state). For collectives,
61the eager selective must-save set (all-to-all/reduce-scatter/in-place
62allreduce) is always saved; other collectives follow the mode policy.
63``ac_save_forward_all_gather`` may opt forward (TP/SP/CP) all-gathers into
64backward replay.
65"""
67from __future__ import annotations
69__all__ = ["ActivationCheckpointPass", "prepare_activation_checkpoint"]
71import functools
72import logging
73import operator
74from types import SimpleNamespace
75from typing import Any, Callable, Dict, List, Optional, Set, Tuple
77import torch
78from torch import fx
79from torch._functorch import compile_utils
80from torch._ops import OpOverload
81from torch.utils.checkpoint import CheckpointPolicy
83# Same-package reuse of the eager AC machinery: operator save/recompute sets,
84# HF transformer-block discovery and model-adapter recompute specs. They are
85# module-level privates because the eager module never exported them, but the
86# coupling is the point: one policy vocabulary for both modes.
87from hyper_parallel.distributed.activation_checkpoint import (
88 _SELECTIVE_AC_COMM_OP_NAMES,
89 _SELECTIVE_AC_FORCE_RECOMPUTE_OPS,
90 _SELECTIVE_AC_MUST_SAVE_OPS,
91 _adapter_recompute_spec,
92 _existing_ops,
93 _find_transformer_layer_container_infos,
94 _resolve_op_attr,
95 _resolve_torch_op,
96 _select_excluded_targets,
97 _select_layer_region_roots,
98 _selected_checkpoint_layer_fqns,
99 _validate_region_exclusions,
100)
102from ..pass_config import PassConfig
103from .base import GraphPass
105_LOG = logging.getLogger(__name__)
107# FSDP all-gathers are inserted after this pass, but forward (TP/SP/CP)
108# all-gathers are already in the traced forward; saving them avoids a second
109# collective during backward recompute. ``PassConfig.ac_save_forward_all_gather``
110# selects that superset. Only the selective communication set is always saved:
111# other collectives follow the mode policy, matching the eager path.
112_GRAPH_AC_COMM_OPS = _existing_ops(
113 *(_resolve_torch_op(name) for name in _SELECTIVE_AC_COMM_OP_NAMES),
114 _resolve_op_attr(torch.ops, "deepep.dispatch.default"),
115 _resolve_op_attr(torch.ops, "deepep.combine.default"),
116 _resolve_op_attr(torch.ops, "hybridep.dispatch.default"),
117 _resolve_op_attr(torch.ops, "hybridep.combine.default"),
118)
119_GRAPH_FORWARD_ALL_GATHER_OPS = _existing_ops(
120 _resolve_torch_op("aten.all_gather_into_tensor"),
121 _resolve_torch_op("_c10d_functional.all_gather_into_tensor"),
122)
125def _resolve_wait_tensor() -> Optional[Any]:
126 """Return the c10d ``wait_tensor`` overload when this torch ships it."""
127 functional = getattr(torch.ops, "_c10d_functional", None)
128 wait = getattr(functional, "wait_tensor", None)
129 if wait is None:
130 return None
131 return getattr(wait, "default", None)
134# getitem / wait_tensor extract or await their producer's value: their
135# save/recompute decision is inherited, and remat walks through them.
136_TRANSPARENT_OPS: Tuple[Any, ...] = tuple(
137 target
138 for target in (operator.getitem, _resolve_wait_tensor())
139 if target is not None
140)
142_RECOMPUTE_POLICIES = (
143 CheckpointPolicy.PREFER_RECOMPUTE,
144 CheckpointPolicy.MUST_RECOMPUTE,
145)
148def _must_recompute(node: fx.Node) -> bool:
149 """Whether ``node`` carries a policy the partitioner would recompute."""
150 return node.meta.get("recompute") in _RECOMPUTE_POLICIES
153def _node_stack_fqns(node: fx.Node) -> List[str]:
154 """Module FQNs on ``node``'s ``nn_module_stack``, innermost first."""
155 stack = node.meta.get("nn_module_stack") or {}
156 return [fqn for fqn, _ in reversed(list(stack.values()))]
159def _at_or_under(fqn: str, root: str) -> bool:
160 """Whether ``fqn`` equals ``root`` or lives below it."""
161 return fqn == root or fqn.startswith(f"{root}.")
164def _first_container(fqns: List[str], roots: frozenset) -> Optional[str]:
165 """Return the first root that contains one of ``fqns`` (innermost wins).
167 ``roots`` is sorted so a (currently impossible) nested-root match is still
168 deterministic. ``fqns`` is innermost-first, so the first hit is the
169 innermost containing root.
170 """
171 for fqn in fqns:
172 for root in sorted(roots):
173 if _at_or_under(fqn, root):
174 return root
175 return None
178class _CheckpointScope:
179 """Resolved recompute scope for one compilation.
181 ``_resolve_scope`` always fills every field; the ``None`` defaults exist
182 for direct construction in tests and mean "no region" / "no boundary
183 units", which makes every node ineligible / disables the cross-unit rule.
185 Attributes:
186 region_fqns: FQNs whose subtree may recompute. Empty (or ``None``)
187 makes every node ineligible (the pass becomes a no-op).
188 exclude_fqns: FQNs whose subtree must stay saved (adapter exclusions).
189 unit_fqns: Boundary units for the cross-unit save rule. Empty (or
190 ``None``) disables the rule.
191 on_applied: Adapter runtime hook that must run before the model's
192 forward is traced, when the adapter's region shape is in effect.
193 """
195 def __init__(
196 self,
197 region_fqns: Optional[frozenset] = None,
198 exclude_fqns: frozenset = frozenset(),
199 unit_fqns: Optional[frozenset] = None,
200 on_applied: Optional[Callable[[torch.nn.Module], None]] = None,
201 ) -> None:
202 """Store the resolved scope membership sets."""
203 self.region_fqns = region_fqns
204 self.exclude_fqns = exclude_fqns
205 self.unit_fqns = unit_fqns
206 self.on_applied = on_applied
209def _adapter_scope(
210 model: torch.nn.Module,
211 selected_layers: frozenset,
212 unit_fqns: frozenset,
213 recompute_spec: Any,
214) -> _CheckpointScope:
215 """Build a scope from the adapter-declared recompute regions."""
216 module_by_fqn = dict(model.named_modules())
217 roots = _select_layer_region_roots(module_by_fqn, selected_layers, recompute_spec)
218 if not roots:
219 raise ValueError(
220 "model adapter recompute patterns matched no region in the selected layers"
221 )
222 excluded = _select_excluded_targets(module_by_fqn, roots, recompute_spec)
223 if recompute_spec.exclude_patterns and not excluded:
224 raise ValueError(
225 "model adapter recompute exclusions matched no module "
226 "inside the selected regions"
227 )
228 _validate_region_exclusions(roots, excluded)
229 return _CheckpointScope(
230 region_fqns=frozenset(fqn for fqn, _ in roots),
231 exclude_fqns=frozenset(fqn for fqn, _ in excluded),
232 unit_fqns=unit_fqns,
233 on_applied=recompute_spec.on_applied,
234 )
237def _resolve_scope(
238 model: Optional[torch.nn.Module], pass_config: PassConfig
239) -> _CheckpointScope:
240 """Resolve the checkpoint scope from the dynamic-config vocabulary.
242 The recompute scope and the boundary units are the discovered
243 transformer blocks — the same HuggingFace-marker discovery the eager
244 path requires (``_resolve_activation_checkpoint_containers``), so a
245 model is AC-eligible in both modes under the same contract. Nodes
246 outside the blocks (embedding, head, the ``train_fn`` loss chain) are
247 never tagged, which also keeps the head/epilogue backward from pulling
248 the whole forward into one recompute chain. With
249 ``ac_layer_count`` / ``ac_layer_indices`` only the selected blocks
250 recompute; an adapter ``RecomputePolicy`` (``model_adapter_safe_regions``
251 in the dynamic config) narrows the selected blocks to its region
252 patterns and pulls ``exclude_patterns`` back out of the recompute.
254 ``full`` with the default selection mirrors the eager auto-upgrade: when
255 the model owns a ``RecomputePolicy``, all blocks are selected through its
256 regions instead of recomputing every op in the blocks.
258 Raises:
259 ValueError: When the live model is missing, no transformer blocks
260 are discovered, an explicitly requested safe-region selection has
261 no adapter, or the adapter declares no recompute region.
262 """
263 if model is None:
264 raise ValueError(
265 "ActivationCheckpointPass requires the live model via kwargs "
266 "(model=...) so it can discover the transformer blocks to "
267 "checkpoint"
268 )
269 containers = _find_transformer_layer_container_infos(model)
270 if not containers:
271 raise ValueError(
272 f"{type(model).__name__} has no module with a 'gradient_checkpointing' "
273 "attribute and a non-empty repeated block container; activation "
274 "checkpointing cannot resolve its layers"
275 )
276 block_fqns = frozenset(
277 block.fqn for container in containers for block in container.blocks
278 )
279 explicit_regions = pass_config.ac_region_source == "model_adapter_safe_regions"
280 has_count = pass_config.ac_layer_count is not None
281 has_indices = pass_config.ac_layer_indices is not None
282 if not has_count and not has_indices:
283 if pass_config.ac_mode == "full":
284 recompute_spec = _adapter_recompute_spec(model, required=explicit_regions)
285 if recompute_spec is not None:
286 if not recompute_spec.region_patterns:
287 raise ValueError(
288 "model adapter recompute requires "
289 "RecomputePolicy.region_patterns"
290 )
291 return _adapter_scope(model, block_fqns, block_fqns, recompute_spec)
292 return _CheckpointScope(region_fqns=block_fqns, unit_fqns=block_fqns)
293 selection = SimpleNamespace(
294 layer_count=pass_config.ac_layer_count,
295 layer_indices=pass_config.ac_layer_indices,
296 )
297 selected_layers = frozenset(_selected_checkpoint_layer_fqns(containers, selection))
298 recompute_spec = _adapter_recompute_spec(model, required=explicit_regions)
299 if explicit_regions:
300 if recompute_spec is None:
301 raise ValueError(
302 "activation_checkpoint.selection.source=model_adapter_safe_regions "
303 "requires ModelAdapterSpec.recompute"
304 )
305 if not recompute_spec.region_patterns:
306 raise ValueError(
307 "model adapter recompute requires RecomputePolicy.region_patterns"
308 )
309 if not selected_layers:
310 # Zero selected layers is a no-op, not an error.
311 _LOG.info("activation checkpoint layer selection matched zero layers")
312 return _CheckpointScope(region_fqns=frozenset(), unit_fqns=block_fqns)
313 if recompute_spec is None or not recompute_spec.region_patterns:
314 return _CheckpointScope(region_fqns=selected_layers, unit_fqns=block_fqns)
315 return _adapter_scope(model, selected_layers, block_fqns, recompute_spec)
318def prepare_activation_checkpoint(
319 model: Optional[torch.nn.Module], pass_config: PassConfig
320) -> _CheckpointScope:
321 """Resolve the AC scope and run the adapter hook before tracing.
323 The adapter's ``on_applied`` hook configures runtime state the model's
324 forward reads, so it must run before the joint graph is traced.
325 ``GraphCompiler`` calls this once pre-trace and hands the scope to the
326 pass, which avoids a second hook invocation.
328 Args:
329 model: The live model whose transformer blocks define the scope.
330 pass_config: Parallel configuration driving ``ac_mode``.
332 Returns:
333 The resolved scope, including the adapter runtime hook when one is
334 declared.
335 """
336 scope = _resolve_scope(model, pass_config)
337 if scope.on_applied is not None and model is not None:
338 scope.on_applied(model)
339 return scope
342def _is_eligible(node: fx.Node, scope: _CheckpointScope) -> bool:
343 """Whether ``node`` may carry a recompute decision at all.
345 Nodes outside the checkpoint regions stay saved: activation
346 checkpointing targets transformer-block forwards, mirroring the eager
347 path that wraps the discovered blocks only.
348 """
349 if not scope.region_fqns:
350 return False
351 fqns = _node_stack_fqns(node)
352 if not fqns:
353 return False
354 if _first_container(fqns, scope.exclude_fqns) is not None:
355 return False
356 return _first_container(fqns, scope.region_fqns) is not None
359def _make_policy_fn(pass_config: PassConfig) -> Callable[[fx.Node], CheckpointPolicy]:
360 """Build the per-node policy for ``ac_mode``.
362 ``full`` recomputes every scoped node. ``selective`` saves the shared
363 compute/communication operator set and forces recompute only on the
364 operators the eager policy must re-replay (in-place-unsafe outputs such
365 as ``topk``); everything else prefers recompute.
366 """
367 if pass_config.ac_mode == "full":
368 return lambda node: CheckpointPolicy.MUST_RECOMPUTE
370 must_save = _SELECTIVE_AC_MUST_SAVE_OPS
371 force_recompute = _SELECTIVE_AC_FORCE_RECOMPUTE_OPS
373 def selective_policy(node: fx.Node) -> CheckpointPolicy:
374 """Apply the shared selective-AC operator policy."""
375 if node.target in force_recompute:
376 return CheckpointPolicy.MUST_RECOMPUTE
377 if node.target in must_save:
378 return CheckpointPolicy.MUST_SAVE
379 return CheckpointPolicy.PREFER_RECOMPUTE
381 return selective_policy
384def _unit_of(node: fx.Node, scope: _CheckpointScope) -> Optional[str]:
385 """Return ``node``'s boundary block unit, if any."""
386 if not scope.unit_fqns:
387 return None
388 return _first_container(_node_stack_fqns(node), scope.unit_fqns)
391def _tag_policy_pass(
392 graph_module: fx.GraphModule,
393 pass_config: PassConfig,
394 scope: _CheckpointScope,
395) -> None:
396 """Tag forward nodes per policy, applying the structural exceptions.
398 RNG ops and SymInt scalars stay saved; getitem/wait_tensor inherit their
399 parent's decision. The selective communication set is saved in every mode
400 (including ``full``), matching the eager selective policy; all-gathers join
401 the saved set when ``ac_save_forward_all_gather`` is enabled. Other
402 collectives (functional all-reduce/broadcast) follow the mode policy.
403 """
404 policy_fn = _make_policy_fn(pass_config)
405 comm_ops = _GRAPH_AC_COMM_OPS
406 if pass_config.ac_save_forward_all_gather:
407 comm_ops = comm_ops | _GRAPH_FORWARD_ALL_GATHER_OPS
408 for node in graph_module.graph.nodes:
409 if node.op != "call_function" or node.meta.get("autograd_backward", False):
410 continue
411 if node.target in _TRANSPARENT_OPS:
412 parent = node.args[0]
413 if isinstance(parent, fx.Node) and "recompute" in parent.meta:
414 node.meta["recompute"] = parent.meta["recompute"]
415 continue
416 if not _is_eligible(node, scope):
417 continue
418 if _is_rng_op(node) or node.target in comm_ops:
419 node.meta["recompute"] = CheckpointPolicy.MUST_SAVE
420 continue
421 value = node.meta.get("val")
422 if isinstance(value, torch.SymInt):
423 node.meta["recompute"] = CheckpointPolicy.MUST_SAVE
424 continue
425 node.meta["recompute"] = policy_fn(node)
428def _flatten_getitems(graph_module: fx.GraphModule) -> None:
429 """Flatten tuple projections when this torch build provides the helper."""
430 raise_getitems = getattr(compile_utils, "raise_getitems", None)
431 if raise_getitems is not None:
432 raise_getitems(graph_module)
435def _is_mutable_op(target: Any) -> bool:
436 """Return whether ``target`` is an in-place ``OpOverload``."""
437 if not isinstance(target, OpOverload):
438 return False
439 return bool(getattr(getattr(target, "_schema", None), "is_mutable", False))
442def _is_rng_op(node: fx.Node) -> bool:
443 """Return True when a node's seeded nondeterminism cannot be ruled out."""
444 return bool(
445 hasattr(node.target, "tags")
446 and torch.Tag.nondeterministic_seeded in node.target.tags
447 )
450def _mutated_arg_nodes(node: fx.Node) -> List[fx.Node]:
451 """Return the input nodes whose tensors ``node`` overwrites in place."""
452 schema = getattr(node.target, "_schema", None)
453 if schema is None:
454 return []
455 mutated: List[fx.Node] = []
456 for position, schema_arg in enumerate(schema.arguments):
457 alias_info = getattr(schema_arg, "alias_info", None)
458 if alias_info is None or not alias_info.is_write:
459 continue
460 arg = (
461 node.args[position]
462 if position < len(node.args)
463 else node.kwargs.get(schema_arg.name)
464 )
465 if isinstance(arg, fx.Node):
466 mutated.append(arg)
467 return mutated
470def _aliased_input_nodes(node: fx.Node) -> List[fx.Node]:
471 """Return input nodes whose storage ``node``'s outputs may share.
473 Alias relationships come from the operator schema. A return carrying
474 ``alias_info`` aliases the arguments whose alias sets intersect; several
475 multi-output views (``split`` / ``chunk`` / ``unbind``) instead mark the
476 input with an ``after_set`` and leave the returns unannotated. In-place
477 writes always share their argument's storage. ``getitem`` picks one output
478 of a producer, so it inherits the producer's storage even though the
479 builtin itself has no schema.
480 """
481 if node.op != "call_function":
482 return []
483 if node.target is operator.getitem:
484 producer = node.args[0] if node.args else None
485 return [producer] if isinstance(producer, fx.Node) else []
486 schema = getattr(node.target, "_schema", None)
487 if schema is None:
488 return []
489 result_names: Set[str] = set()
490 for result in schema.returns:
491 alias_info = getattr(result, "alias_info", None)
492 if alias_info is not None:
493 result_names.update(alias_info.before_set)
494 result_names.update(alias_info.after_set)
495 wildcard_result = "*" in result_names
496 aliased: List[fx.Node] = []
497 for position, schema_arg in enumerate(schema.arguments):
498 alias_info = getattr(schema_arg, "alias_info", None)
499 if alias_info is None:
500 continue
501 arg_names = set(alias_info.before_set) | set(alias_info.after_set)
502 if not (
503 getattr(alias_info, "is_write", False)
504 or wildcard_result
505 or "*" in arg_names
506 or bool(arg_names & result_names)
507 ):
508 continue
509 arg = (
510 node.args[position]
511 if position < len(node.args)
512 else node.kwargs.get(schema_arg.name)
513 )
514 if isinstance(arg, fx.Node):
515 aliased.append(arg)
516 return aliased
519def _forward_alias_neighbors(
520 graph_module: fx.GraphModule,
521) -> Dict[fx.Node, Set[fx.Node]]:
522 """Build the undirected shared-storage graph over the forward graph."""
523 neighbors: Dict[fx.Node, Set[fx.Node]] = {}
524 for node in graph_module.graph.nodes:
525 if node.meta.get("autograd_backward", False):
526 continue
527 for arg in _aliased_input_nodes(node):
528 if arg.meta.get("autograd_backward", False):
529 continue
530 neighbors.setdefault(node, set()).add(arg)
531 neighbors.setdefault(arg, set()).add(node)
532 return neighbors
535def _alias_closure(
536 roots: Set[fx.Node], neighbors: Dict[fx.Node, Set[fx.Node]]
537) -> Set[fx.Node]:
538 """Return every forward node sharing storage with any of ``roots``.
540 The walk is undirected so a mutation through one view reaches its base
541 (backward along the alias edge) and then the sibling views of that base
542 (forward along their edges).
543 """
544 closure = set(roots)
545 stack = list(roots)
546 while stack:
547 current = stack.pop()
548 for neighbor in neighbors.get(current, ()):
549 if neighbor not in closure:
550 closure.add(neighbor)
551 stack.append(neighbor)
552 return closure
555def _tag_inplace_saves(graph_module: fx.GraphModule) -> None:
556 """Force saves around in-place mutations and their aliased-storage readers.
558 The mutation must run exactly once in forward, so it stays saved together
559 with its forward consumers. Every forward reader of the mutated storage
560 stays saved as well: a recompute duplicate would re-execute against either
561 the post-mutation original or a pre-mutation duplicate of the producer,
562 and neither matches what forward computed at the reader's own position.
563 Eager autograd stays silent for such a read — its value reaches backward
564 only through the recompute chain, never as a saved tensor. The storage
565 closure follows operator-schema aliases, so readers of a view (or of a
566 sibling view when the write goes through another view) are covered, and
567 mutations outside the checkpoint scope still contribute their storage.
568 """
569 nodes = list(graph_module.graph.nodes)
570 mutated_bases: Set[fx.Node] = set()
571 for node in nodes:
572 if (
573 node.op != "call_function"
574 or node.meta.get("autograd_backward", False)
575 or not _is_mutable_op(node.target)
576 ):
577 continue
578 mutated_bases.update(_mutated_arg_nodes(node))
579 if "recompute" not in node.meta:
580 continue
581 node.meta["recompute"] = CheckpointPolicy.MUST_SAVE
582 for user in node.users:
583 if (
584 not user.meta.get("autograd_backward", False)
585 and "recompute" in user.meta
586 ):
587 user.meta["recompute"] = CheckpointPolicy.MUST_SAVE
588 if not mutated_bases:
589 return
590 aliased_storage = _alias_closure(
591 mutated_bases, _forward_alias_neighbors(graph_module)
592 )
593 for node in nodes:
594 if (
595 node.op != "call_function"
596 or node.meta.get("autograd_backward", False)
597 or node.meta.get("recompute") not in _RECOMPUTE_POLICIES
598 ):
599 continue
600 if not aliased_storage.isdisjoint(node.all_input_nodes):
601 node.meta["recompute"] = CheckpointPolicy.MUST_SAVE
604def _tag_cross_unit_saves(
605 graph_module: fx.GraphModule, scope: _CheckpointScope
606) -> None:
607 """Save recomputable nodes read forward across a unit boundary.
609 Their original activation stays alive for the forward consumer anyway, so
610 recomputing them in backward would be pure waste.
611 """
612 for node in graph_module.graph.nodes:
613 if (
614 node.op != "call_function"
615 or node.meta.get("autograd_backward", False)
616 or node.meta.get("recompute") not in _RECOMPUTE_POLICIES
617 ):
618 continue
619 if _has_cross_unit_forward_consumer(node, scope):
620 node.meta["recompute"] = CheckpointPolicy.MUST_SAVE
623def _tag_sac_policy(
624 graph_module: fx.GraphModule,
625 pass_config: PassConfig,
626 scope: _CheckpointScope,
627) -> None:
628 """Annotate forward nodes with ``meta["recompute"]`` policies.
630 Pass 1 tags per policy with the structural exceptions; pass 2 forces saves
631 on in-place mutations, their consumers and the forward readers of the
632 mutated storage (view aliases included); pass 3 forces saves on
633 recomputable nodes read forward across a unit boundary (their original
634 stays alive anyway, so the backward recompute would be pure waste).
635 """
636 if not scope.region_fqns:
637 return
638 _tag_policy_pass(graph_module, pass_config, scope)
639 _tag_inplace_saves(graph_module)
640 if scope.unit_fqns:
641 _tag_cross_unit_saves(graph_module, scope)
644def _has_cross_unit_forward_consumer(node: fx.Node, scope: _CheckpointScope) -> bool:
645 """Whether ``node`` is read forward outside its own boundary unit.
647 Transparent (inherited) getitem/wait_tensor chains are walked through;
648 any other forward consumer in a different unit (or outside every unit,
649 e.g. the epilogue) means the original must stay alive anyway.
650 """
651 node_unit = _unit_of(node, scope)
652 policy = node.meta["recompute"]
653 seen = {node}
654 stack = [node]
655 while stack:
656 current = stack.pop()
657 for user in current.users:
658 if user in seen:
659 continue
660 seen.add(user)
661 if user.op == "output" or user.meta.get("autograd_backward", False):
662 continue
663 if (
664 user.op == "call_function"
665 and user.target in _TRANSPARENT_OPS
666 and user.meta.get("recompute") == policy
667 ):
668 stack.append(user)
669 continue
670 if _unit_of(user, scope) != node_unit:
671 return True
672 return False
675def _get_attr_value(graph_module: fx.GraphModule, target: str) -> Any:
676 """Resolve an FX ``get_attr`` target, including dotted qualified names."""
677 try:
678 return graph_module.get_submodule(target)
679 except AttributeError:
680 return getattr(graph_module, target, None)
683def _duplicate_subgraph_get_attrs(
684 graph_module: fx.GraphModule, fwd_node: fx.Node, bwd_target: fx.Node
685) -> Dict[fx.Node, fx.Node]:
686 """Give a recompute duplicate private copies of HOP subgraph get_attrs.
688 Higher-order ops (flex_attention etc.) reference their subgraphs through
689 ``get_attr`` nodes. Sharing the original would let a later regional
690 Inductor pass place the shared subgraph in only one partition; fresh
691 get_attr nodes keep each region self-contained.
692 """
693 duplicates: Dict[fx.Node, fx.Node] = {}
694 for arg in fwd_node.all_input_nodes:
695 if arg.op != "get_attr":
696 continue
697 if not isinstance(_get_attr_value(graph_module, arg.target), fx.GraphModule):
698 continue
699 with graph_module.graph.inserting_before(bwd_target):
700 dup_attr = graph_module.graph.get_attr(arg.target)
701 dup_attr.meta.update(arg.meta)
702 duplicates[arg] = dup_attr
703 return duplicates
706def _remat(graph_module: fx.GraphModule) -> int:
707 """Duplicate tagged recompute nodes in front of their backward consumers.
709 Returns the number of duplicated forward nodes. The graph is mutated in
710 place; originals whose consumers were all redirected are erased, and
711 ``raise_getitems`` flattens output/tuple projections for better memory.
712 """
713 graph = graph_module.graph
714 all_nodes = list(graph.nodes)
715 order = {node: idx for idx, node in enumerate(all_nodes)}
717 # Map each tagged forward node to its earliest backward consumer; the
718 # transitive walk through tagged ancestors mirrors the partitioner's
719 # recompute-closure gathering (saved ancestors stay live, so only tagged
720 # ones need duplicating).
721 remat_targets: Dict[fx.Node, fx.Node] = {}
722 for bwd_node in all_nodes:
723 if not bwd_node.meta.get("autograd_backward", False):
724 continue
725 seen: Set[fx.Node] = set()
726 stack = list(bwd_node.all_input_nodes)
727 while stack:
728 current = stack.pop()
729 if current in seen or not _must_recompute(current):
730 continue
731 seen.add(current)
732 remat_targets.setdefault(current, bwd_node)
733 stack.extend(current.all_input_nodes)
735 if not remat_targets:
736 return 0
738 recomputed: Dict[fx.Node, fx.Node] = {}
740 def remat_arg(arg: Any, get_attr_dups: Dict[fx.Node, fx.Node]) -> Any:
741 """Redirect duplicate args to their recompute copies."""
742 if isinstance(arg, fx.Node):
743 if arg in get_attr_dups:
744 return get_attr_dups[arg]
745 if arg in recomputed:
746 return recomputed[arg]
747 return arg
749 for fwd_node in sorted(remat_targets, key=order.__getitem__):
750 bwd_target = remat_targets[fwd_node]
751 get_attr_dups = _duplicate_subgraph_get_attrs(
752 graph_module, fwd_node, bwd_target
753 )
754 with graph.inserting_before(bwd_target):
755 duplicate = graph.node_copy(
756 fwd_node,
757 functools.partial(remat_arg, get_attr_dups=get_attr_dups),
758 )
759 # Leave the name to ``node_copy``/``create_node`` so the graph's name
760 # namespace owns uniqueness; a direct ``.name =`` assignment bypasses
761 # it and can collide with an existing node.
762 duplicate.meta["autograd_backward"] = True
763 duplicate.meta["ac_recomputed"] = True
764 duplicate.meta["ac_recomputed_from"] = fwd_node.name
765 # node_copy shares meta, including the policy tag. Drop it so a second
766 # pass run cannot mistake the duplicate for a recompute candidate.
767 duplicate.meta.pop("recompute", None)
768 recomputed[fwd_node] = duplicate
770 def redirect(arg: Any) -> Any:
771 """Arg transform for backward consumers of recomputed originals."""
772 if isinstance(arg, fx.Node) and arg in recomputed:
773 return recomputed[arg]
774 return arg
776 direct_backward_consumers = {
777 user
778 for fwd_node in recomputed
779 for user in fwd_node.users
780 if user.meta.get("autograd_backward", False)
781 }
782 for bwd_node in direct_backward_consumers:
783 bwd_node.args = fx.map_arg(bwd_node.args, redirect)
784 bwd_node.kwargs = fx.map_arg(bwd_node.kwargs, redirect)
786 for original in reversed(list(recomputed)):
787 if not original.users:
788 graph.erase_node(original)
790 _flatten_getitems(graph_module)
791 graph.lint()
792 # ``raise_getitems`` recompiles when present; the explicit call also covers
793 # torch builds without it, where the inserted/erased nodes would otherwise
794 # leave the GraphModule's generated code stale.
795 graph_module.recompile()
796 return len(recomputed)
799class ActivationCheckpointPass(GraphPass):
800 """Static-graph activation checkpointing (tag + recompute-materialize).
802 Configure through ``PassConfig.ac_mode`` (``"off"`` / ``"full"`` /
803 ``"selective"``) and the optional ``ac_layer_count`` /
804 ``ac_layer_indices`` layer selectors. The trainer-level
805 ``activation_checkpoint`` section is projected onto these fields by
806 ``build_pass_config_from_trainer_config``.
807 """
809 name = "activation_checkpoint"
811 def run(
812 self,
813 graph_module: fx.GraphModule,
814 pass_config: PassConfig,
815 **kwargs: Any,
816 ) -> fx.GraphModule:
817 """Tag the joint graph and materialize recompute duplicates.
819 Args:
820 graph_module: The joint forward+backward FX graph.
821 pass_config: Parallel configuration driving ``ac_mode``.
822 **kwargs: ``model`` (the live module) is required whenever
823 ``ac_mode != "off"``: block discovery resolves the recompute
824 scope from it. ``ac_scope`` is the pre-resolved scope from
825 ``GraphCompiler`` (it also ran the adapter hook before
826 tracing); direct callers may omit it.
828 Returns:
829 The transformed graph module.
830 """
831 if pass_config.ac_mode == "off":
832 return graph_module
833 scope = kwargs.get("ac_scope")
834 if scope is None:
835 scope = prepare_activation_checkpoint(kwargs.get("model"), pass_config)
836 already_rematted = any(
837 node.meta.get("ac_recomputed") for node in graph_module.graph.nodes
838 )
839 _tag_sac_policy(graph_module, pass_config, scope)
840 duplicated = _remat(graph_module)
841 _LOG.info(
842 "Activation checkpointing (mode=%s) recomputed %d node(s)",
843 pass_config.ac_mode,
844 duplicated,
845 )
846 if duplicated == 0 and scope.region_fqns and not already_rematted:
847 _LOG.warning(
848 "activation checkpointing matched scope %s but recomputed no "
849 "node; the model may already be wrapped by eager activation "
850 "checkpointing, so graph-mode recompute is inactive",
851 sorted(scope.region_fqns),
852 )
853 return graph_module