33
44# DeepSpeed Team
55
6+ import functools
67import os
78import time
89from collections import OrderedDict , defaultdict
6263# the two halves of the pass find each other.
6364_offload_plans : Dict [int , "OrderedDict[str, Tuple[int, int]]" ] = {}
6465
66+ # Bytes each planned value keeps allocated if it stays on the device, keyed the same way. This is
67+ # not the size of the copy: a saved view holds its whole base allocation alive, so a 4KB row of a
68+ # 4MB tensor costs 4MB of residency and 4KB of copy. The planner spends headroom in residency
69+ # bytes, while the backward pass schedules its copies in copy bytes.
70+ _resident_bytes : Dict [int , Dict [str , int ]] = {}
71+
6572# Value ids identify a host buffer inside the C++ executor. They never repeat, so a buffer holding
6673# a tensor of one shape is never reused for another.
6774_next_value_id = 0
@@ -279,6 +286,27 @@ def _skip_bytes(node) -> int:
279286 return 0
280287
281288
289+ @functools .lru_cache
290+ def _aliasing_ops ():
291+ """Ops whose output shares storage with an input, for the purpose of tracking that storage.
292+
293+ get_no_copy_ops() reads the aten schemas, and aten._unsafe_view declares a fresh tensor return
294+ even though it hands back a view -- that declaration is the entire point of the op. It shares
295+ storage all the same, and AOTAutograd emits it after nearly every matmul, so this pass has to
296+ know about it. It is added here rather than in get_no_copy_ops() because that set also decides
297+ where the ZeRO-3 passes release parameters, and this pass has no business changing that.
298+ """
299+ return frozenset (get_no_copy_ops () | {torch .ops .aten ._unsafe_view .default })
300+
301+
302+ def _zero3_gathered_param_ops ():
303+ """The op that produces a ZeRO-3 gathered parameter buffer, empty if DeepCompile is not built."""
304+ try :
305+ return {torch .ops .dc .allgather_param .default }
306+ except (AttributeError , RuntimeError ):
307+ return set ()
308+
309+
282310def _alias_root (node : Node , no_copy_ops , cache : Dict [Node , Node ]) -> Node :
283311 """The node that allocated the storage `node` reads.
284312
@@ -311,45 +339,67 @@ def _storage_keeper_counts(graph: Graph, saved_nodes: List[Node], returned_to_ca
311339 """How many values that outlive the forward pass hold each storage.
312340
313341 Moving a saved value to the host releases its memory only if nothing else still points at that
314- storage. Three kinds of value still do: the graph's own inputs, which the caller owns; the
315- values the graph returns to the caller; and every other saved value.
342+ storage. Four kinds of value still do:
343+
344+ - the graph's own inputs, which the caller owns;
345+ - get_attr nodes, whose tensor the GraphModule holds for the life of the process (attention
346+ masks, rotary embedding tables, and the other constants inductor bakes in);
347+ - ZeRO-3 gathered parameters, whose buffer belongs to ZeRO's own registry and is released by
348+ release_param, not by this graph. The forward graph reaches one of these through
349+ dc.wait_allgather, which is an aliasing op, so without this the gathered weight looks like
350+ an ordinary activation with nothing else holding it;
351+ - the values the graph returns to the caller, and every other saved value.
316352 """
317353 counts = defaultdict (int )
318- keepers = [node for node in graph .nodes if node .op == "placeholder" ]
354+ gather_ops = _zero3_gathered_param_ops ()
355+ keepers = [node for node in graph .nodes if node .op in ("placeholder" , "get_attr" ) or node .target in gather_ops ]
319356 keepers .extend (returned_to_caller )
320357 keepers .extend (saved_nodes )
321358 for keeper in keepers :
322359 counts [_alias_root (keeper , no_copy_ops , cache )] += 1
323360 return counts
324361
325362
326- def _has_reloadable_layout (node : Node ) -> bool :
327- """Whether the host round trip hands the backward pass back the tensor it was compiled for .
363+ def _has_non_overlapping_storage (node : Node ) -> bool :
364+ """Whether the tensor's elements each occupy their own place in storage .
328365
329- Both the host buffer and the reloaded device tensor are made with `empty_like`, which
330- reproduces the strides of a tensor only when that tensor is non-overlapping and dense. A view
331- that is not -- a strided slice, or an `expand`, whose zero strides also make `empty_like`
332- allocate the materialized size instead of the much smaller storage the view really holds --
333- comes back laid out differently from what the backward graph expects.
366+ Both the host buffer and the reloaded device tensor are made with `empty_like`, which allocates
367+ one element per logical element. A tensor whose elements overlap holds fewer elements of
368+ storage than it has entries -- `expand` is the ordinary case, repeating a row with a stride of
369+ zero -- so the round trip would copy and allocate several times what the value actually keeps
370+ alive. An expanded row of 1000 floats seen as 4x1000 copies 16KB each way to release 4KB.
371+
372+ Strides that are merely non-contiguous are fine and are deliberately allowed. `empty_like` does
373+ return a contiguous tensor for a strided slice or one piece of a split, so the reload hands the
374+ backward pass different strides than the traced metadata promises. That was measured on torch
375+ 2.6.0+cu124 with torch._inductor.config.size_asserts off, as init_z3.py sets it: an opaque op
376+ whose meta claims stride (576, 8, 72, 1) while returning (192, 8, 24, 1), consumed by matmul,
377+ batched matmul, linear and reductions under torch.compile, matched eager exactly in every case.
334378 """
335379 val = node .meta .get ("val" )
336380 if not isinstance (val , torch .Tensor ):
337381 return False
338- shape , strides = val .shape , val .stride ()
382+ try :
383+ shape , strides = val .shape , val .stride ()
384+ except (RuntimeError , NotImplementedError ):
385+ # Sparse, nested and other non-strided layouts have no strides to compare.
386+ return False
339387 # Symbolic sizes or strides cannot be checked here; the static-size rule rejects them anyway.
340388 if any (not isinstance (dim , int ) for dim in (* shape , * strides )):
341389 return False
342390
343- expected = 1
344- for stride , size in sorted ((s , d ) for d , s in zip (shape , strides ) if d != 1 ):
345- if stride != expected :
346- return False
347- expected *= size
348- return True
391+ # How many elements of storage the tensor spans, against how many entries it has.
392+ span = 1 + sum ((size - 1 ) * abs (stride ) for size , stride in zip (shape , strides ))
393+ return val .numel () <= span
394+
349395
396+ def _eligible_activations (graph : Graph , graph_id : int , num_fwd_outputs , param_manager ) -> List [Tuple [Node , int , int ]]:
397+ """Every saved activation this pass is allowed to move, largest first.
350398
351- def _eligible_activations (graph : Graph , graph_id : int , num_fwd_outputs , param_manager ) -> List [Tuple [Node , int ]]:
352- """Every saved activation this pass is allowed to move, largest first."""
399+ Each entry is (node, bytes copied, bytes kept allocated if the value stays resident). The two
400+ sizes differ for a view: the copy carries the view's own bytes while residency holds the whole
401+ allocation the view points into.
402+ """
353403 output_node = get_output_node (graph )
354404 outputs = output_node .args [0 ]
355405 if not isinstance (outputs , (list , tuple )):
@@ -365,7 +415,7 @@ def _eligible_activations(graph: Graph, graph_id: int, num_fwd_outputs, param_ma
365415
366416 returned_to_caller = set (node for node in outputs [:num_fwd_outputs ] if isinstance (node , Node ))
367417 param_names = set (param_manager [graph_id ].param_names ) if graph_id in param_manager else set ()
368- no_copy_ops = get_no_copy_ops ()
418+ no_copy_ops = _aliasing_ops ()
369419
370420 # No profile means no peak to plan against, and the usual reason it is missing is that profiling
371421 # itself ran out of memory -- which is evidence of exactly the pressure this pass relieves. Take
@@ -398,13 +448,18 @@ def _eligible_activations(graph: Graph, graph_id: int, num_fwd_outputs, param_ma
398448 # nothing while another value that outlives the forward pass still points at that storage.
399449 # When no other value does -- AOTAutograd routinely saves the view and not the tensor it
400450 # came from -- this view is the last holder, and moving it releases the whole allocation.
401- if node .target in no_copy_ops :
402- if storage_keepers [_alias_root (node , no_copy_ops , alias_roots )] > 1 :
403- _skipped ["alias" ] += _skip_bytes (node )
404- continue
405- if not _has_reloadable_layout (node ):
406- _skipped ["alias_layout" ] += _skip_bytes (node )
407- continue
451+ root = _alias_root (node , no_copy_ops , alias_roots )
452+ # root is node for an aliasing op only when the walk could not find the tensor it aliases,
453+ # and an unknown base is not a base this pass may assume is dead.
454+ if node .target in no_copy_ops and (root is node or storage_keepers [root ] > 1 ):
455+ _skipped ["alias" ] += _skip_bytes (node )
456+ continue
457+ # Checked for every candidate, not only the ones the rule above let through: a piece of a
458+ # split reaches here through operator.getitem, which is not an aliasing op, so the alias
459+ # rule never sees it even though the tensor is a view.
460+ if not _has_non_overlapping_storage (node ):
461+ _skipped ["overlapping" ] += _skip_bytes (node )
462+ continue
408463 # Only floating-point values are activations. The rest are bookkeeping the backward pass
409464 # needs -- indices, masks, and the random-number state that attention saves. That state is
410465 # the reason this test cannot be a device check: it lives on the host, but an op's traced
@@ -416,7 +471,11 @@ def _eligible_activations(graph: Graph, graph_id: int, num_fwd_outputs, param_ma
416471 if size is None or size < min_size :
417472 _skipped ["too_small" if size is not None else "no_static_size" ] += _skip_bytes (node )
418473 continue
419- candidates .append ((node , size ))
474+ # Keeping this value resident holds its whole allocation, which for a view is the base's.
475+ # The alias rule above guarantees this view is the only saved value pointing there, so no
476+ # two entries ever charge the planner for the same bytes.
477+ resident = _static_tensor_size (root ) if root is not node else size
478+ candidates .append ((node , size , resident if resident is not None else size ))
420479
421480 if _skipped :
422481 breakdown = " " .join (f"{ k } ={ v } " for k , v in sorted (_skipped .items ()))
@@ -477,6 +536,7 @@ def _offload_everything_fwd(gm: GraphModule, graph_id: int, profiling_results, p
477536 graph = gm .graph
478537 # A later compile phase plans again from the original graph, so drop any earlier plan first.
479538 _offload_plans [graph_id ] = OrderedDict ()
539+ _resident_bytes [graph_id ] = {}
480540
481541 _report_partitioner_split (graph , graph_id , profiling_results [graph_id ].num_fwd_outputs )
482542
@@ -485,7 +545,7 @@ def _offload_everything_fwd(gm: GraphModule, graph_id: int, profiling_results, p
485545 return None
486546
487547 output_node = get_output_node (graph )
488- for node , size in selected :
548+ for node , size , resident in selected :
489549 value_id = _new_value_id ()
490550 # The graph is re-read for every tensor because each insertion changes it.
491551 insert_before = _insertion_point_after_last_use (list (graph .nodes ), node )
@@ -508,11 +568,13 @@ def _offload_everything_fwd(gm: GraphModule, graph_id: int, profiling_results, p
508568
509569 output_node .replace_input_with (node , wait_node )
510570 _offload_plans [graph_id ][node .name ] = (value_id , size )
571+ _resident_bytes .setdefault (graph_id , {})[node .name ] = resident
511572 _stats ["offload_nodes" ] += 1
512573
513574 graph .lint ()
514575 print_rank_0 (f"offload_activation graph_id={ graph_id } floor: moved all { len (selected )} eligible "
515- f"activations ({ sum (size for _ , size in selected ) / 1e9 :.1f} GB) before profiling" )
576+ f"activations ({ sum (size for _ , size , _ in selected ) / 1e9 :.1f} GB copied, "
577+ f"{ sum (resident for _ , _ , resident in selected ) / 1e9 :.1f} GB released) before profiling" )
516578 # Returned, not None: the caller profiles what it gets back, and that profile is the floor the
517579 # planner needs.
518580 return gm
@@ -622,16 +684,21 @@ def _plan_against_floor_fwd(gm: GraphModule, graph_id: int, profiling_results) -
622684 print_rank_0 (f"offload_activation graph_id={ graph_id } { margin_note } "
623685 f"floor_peak={ floor_peak } budget={ budget } headroom={ headroom } " )
624686
625- # Largest first: each one returned buys back the most memory per copy avoided.
687+ # Largest first: each one returned buys back the most memory per copy avoided. What it costs
688+ # is residency, which for a saved view is the whole allocation the view points into, not the
689+ # view's own bytes. Charging the copy size here would let a handful of small views of large
690+ # tensors retain many times the headroom the planner thinks it spent.
691+ resident_bytes = _resident_bytes .get (graph_id , {})
626692 by_size = sorted (plan .items (), key = lambda item : item [1 ][1 ], reverse = True )
627693 kept_resident = 0
628694 for name , (_ , size ) in by_size :
629- if size > headroom :
695+ cost = resident_bytes .get (name , size )
696+ if cost > headroom :
630697 continue
631698 _bring_back (gm .graph , name )
632699 del plan [name ]
633- headroom -= size
634- kept_resident += size
700+ headroom -= cost
701+ kept_resident += cost
635702 _stats ["offload_nodes" ] -= 1
636703
637704 moved_bytes = sum (size for _ , size in plan .values ())
0 commit comments