Skip to content

Commit d6c9cd9

Browse files
committed
fix
Signed-off-by: pengdurice <pengduhit@gmail.com>
1 parent da9e7c6 commit d6c9cd9

2 files changed

Lines changed: 240 additions & 40 deletions

File tree

deepspeed/compile/passes/offload_activation.py

Lines changed: 101 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
# DeepSpeed Team
55

6+
import functools
67
import os
78
import time
89
from collections import OrderedDict, defaultdict
@@ -62,6 +63,12 @@
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+
282310
def _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

Comments
 (0)