Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
from ._IR import Pipe, pipe_split, pipeline, SplitPoint
|
||||
from .schedules import (
|
||||
_ScheduleForwardOnly,
|
||||
Schedule1F1B,
|
||||
ScheduleDualPipeV,
|
||||
ScheduleGPipe,
|
||||
ScheduleInterleaved1F1B,
|
||||
ScheduleInterleavedZeroBubble,
|
||||
ScheduleLoopedBFS,
|
||||
ScheduleZBVZeroBubble,
|
||||
)
|
||||
from .stage import build_stage, PipelineStage
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Pipe",
|
||||
"pipe_split",
|
||||
"SplitPoint",
|
||||
"pipeline",
|
||||
"PipelineStage",
|
||||
"build_stage",
|
||||
"Schedule1F1B",
|
||||
"ScheduleGPipe",
|
||||
"ScheduleInterleaved1F1B",
|
||||
"ScheduleLoopedBFS",
|
||||
"ScheduleInterleavedZeroBubble",
|
||||
"ScheduleZBVZeroBubble",
|
||||
"ScheduleDualPipeV",
|
||||
]
|
||||
@@ -0,0 +1,460 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
import collections
|
||||
import logging
|
||||
from collections.abc import Iterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch.autograd.graph import GradientEdge, Node
|
||||
from torch.nn import Parameter
|
||||
|
||||
from ._debug import map_debug_info
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_grad_fn_or_grad_acc(t: torch.Tensor) -> Node | None:
|
||||
"""
|
||||
Get the grad function or grad accumulator for a tensor.
|
||||
|
||||
Accumulate grad nodes are lazily created, so we need to a
|
||||
dummy view in order to trigger its creation.
|
||||
"""
|
||||
if t.requires_grad and t.grad_fn is None:
|
||||
# if no grad function (leaf tensors) we use view
|
||||
viewed_t = t.view_as(t)
|
||||
grad_fn = viewed_t.grad_fn
|
||||
if grad_fn is not None:
|
||||
return grad_fn.next_functions[0][0]
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Attempted to get grad_fn, but got None."
|
||||
"Is this being created in a no-grad context?"
|
||||
)
|
||||
else:
|
||||
return t.grad_fn
|
||||
|
||||
|
||||
def reverse_closure(
|
||||
roots: list[Node], target_nodes: set[Node], reverse_edges_dict
|
||||
) -> tuple[set[Node], set[Node]]:
|
||||
"""
|
||||
This function returns the reverse closure of the given roots,
|
||||
i.e. the set of nodes that can be reached from the roots by following the
|
||||
reverse edges of the graph. The target_nodes are the nodes that we want to
|
||||
include in the closure.
|
||||
"""
|
||||
# Recurse until we reach a target node
|
||||
closure: set[Node] = set()
|
||||
visited_target_nodes = set()
|
||||
q: collections.deque[Node] = collections.deque()
|
||||
for node in roots:
|
||||
if node is not None and node not in closure:
|
||||
closure.add(node)
|
||||
q.append(node)
|
||||
while q:
|
||||
node = q.popleft()
|
||||
reverse_edges = reverse_edges_dict[node]
|
||||
for fn in reverse_edges:
|
||||
if fn in closure or fn is None:
|
||||
continue
|
||||
if fn in target_nodes:
|
||||
visited_target_nodes.add(fn)
|
||||
continue
|
||||
closure.add(fn)
|
||||
q.append(fn)
|
||||
return closure, visited_target_nodes
|
||||
|
||||
|
||||
def construct_reverse_graph(roots: list[Node]) -> dict[Node, list[Node]]:
|
||||
q: collections.deque[Node] = collections.deque()
|
||||
root_seen: set[Node] = set()
|
||||
reverse_edges_dict: dict[Node, list[Node]] = collections.defaultdict(list)
|
||||
for node in roots:
|
||||
if node is not None and node not in root_seen:
|
||||
q.append(node)
|
||||
root_seen.add(node)
|
||||
while q:
|
||||
node = q.popleft()
|
||||
for fn, _ in node.next_functions:
|
||||
if fn is not None:
|
||||
if len(reverse_edges_dict[fn]) == 0:
|
||||
q.append(fn)
|
||||
reverse_edges_dict[fn].append(node)
|
||||
return reverse_edges_dict
|
||||
|
||||
|
||||
def get_param_groups(
|
||||
inputs: list[Node], params: list[Node], reverse_edges_dict
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Given a list of inputs and a list of parameters, return a list of parameter
|
||||
groups, where each group contains the parameters and the intermediates that
|
||||
are connected to the parameters.
|
||||
|
||||
The returned list of parameter groups is a list of dictionaries, where each
|
||||
dictionary contains the following keys:
|
||||
- "params": a set of parameters
|
||||
- "intermediates": a set of intermediates
|
||||
|
||||
The returned list of parameter groups is a list of dictionaries,
|
||||
"""
|
||||
# reverse graph that starts with inputs, and goes up to the dOutput or the loss,
|
||||
# but omits weights and any subgraphs connecting weights to this closure
|
||||
inputs_closure, _ = reverse_closure(inputs, set(), reverse_edges_dict)
|
||||
param_groups: dict[Node, dict[str, set]] = dict() # keyed on intermediates
|
||||
for param in params:
|
||||
closure, intersected = reverse_closure(
|
||||
[param], inputs_closure, reverse_edges_dict
|
||||
)
|
||||
param_group: dict[str, set] = {
|
||||
"params": {param},
|
||||
"intermediates": intersected,
|
||||
}
|
||||
for input_node in intersected:
|
||||
existing = param_groups.get(input_node)
|
||||
if existing is not None:
|
||||
existing["params"] = existing["params"].union(param_group["params"])
|
||||
existing["intermediates"] = existing["intermediates"].union(
|
||||
param_group["intermediates"]
|
||||
)
|
||||
param_group = existing
|
||||
else:
|
||||
param_groups[input_node] = param_group
|
||||
|
||||
# Sanity check: union of all param_groups params should be equal to all params
|
||||
union_params: set[Node] = set()
|
||||
seen_ids: set[int] = set()
|
||||
unique_param_groups = []
|
||||
for param_group in param_groups.values():
|
||||
if id(param_group) not in seen_ids:
|
||||
seen_ids.add(id(param_group))
|
||||
unique_param_groups.append(param_group)
|
||||
union_params = union_params.union(param_group["params"])
|
||||
|
||||
# The assert will only be true if the input tensor requires gradients,
|
||||
# otherwise the autograd graph will miss the first layer of inputs
|
||||
# assert union_params == set(params)
|
||||
return unique_param_groups
|
||||
|
||||
|
||||
def _autograd_grad_for_inputs(
|
||||
outputs: Sequence[torch.Tensor],
|
||||
inputs: Sequence[torch.Tensor],
|
||||
grad_outputs: Sequence[torch.Tensor | None] | None = None,
|
||||
retain_graph: bool = False,
|
||||
allow_unused: bool = False,
|
||||
) -> tuple[torch.Tensor | None, ...]:
|
||||
"""Compute input gradients, returning ``None`` for non-grad inputs."""
|
||||
# Some inputs may not be used or may not require gradients, so we filter them out
|
||||
# before calling autograd.grad and place None for those positions in the result.
|
||||
grad_indices: list[int] = []
|
||||
inputs_requiring_grad: list[torch.Tensor] = []
|
||||
for i, inp in enumerate(inputs):
|
||||
if isinstance(inp, torch.Tensor) and inp.requires_grad:
|
||||
grad_indices.append(i)
|
||||
inputs_requiring_grad.append(inp)
|
||||
|
||||
if not inputs_requiring_grad:
|
||||
return tuple(None for _ in inputs)
|
||||
|
||||
grads = torch.autograd.grad(
|
||||
outputs=outputs,
|
||||
inputs=inputs_requiring_grad,
|
||||
grad_outputs=grad_outputs,
|
||||
retain_graph=retain_graph,
|
||||
allow_unused=allow_unused,
|
||||
)
|
||||
|
||||
result: list[torch.Tensor | None] = [None] * len(inputs)
|
||||
for idx, g in zip(grad_indices, grads, strict=True):
|
||||
result[idx] = g
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def stage_backward_input(
|
||||
stage_outputs_or_loss: list[torch.Tensor],
|
||||
output_grads: list[torch.Tensor] | None,
|
||||
input_values: list[torch.Tensor],
|
||||
weights: Iterator[Parameter],
|
||||
) -> tuple[tuple[torch.Tensor | None, ...], list[dict[str, Any]]]:
|
||||
"""
|
||||
Compute the gradients for only the stage inputs with
|
||||
respect to the stage outputs (if non-last stage) or loss (if last stage)
|
||||
|
||||
After computing input gradients, we save the intermediate nodes in `param_groups`
|
||||
for later use in stage_backward_weight. We don't need to save any other intermediate nodes
|
||||
that aren't needed for dW because when we do dW calculation, we start from saved intermediates.
|
||||
Detaching the stage_outputs_or_loss at the end of this function is important as
|
||||
it frees up the memory that the autograd graph is anticipating to be used later (but doesn't actually need).
|
||||
"""
|
||||
stage_output_grad_fns: list[Node] = list(
|
||||
filter(None, map(_get_grad_fn_or_grad_acc, stage_outputs_or_loss))
|
||||
)
|
||||
stage_input_grad_fns: list[Node] = list(
|
||||
filter(None, map(_get_grad_fn_or_grad_acc, input_values))
|
||||
)
|
||||
weight_grad_fns: list[Node] = list(
|
||||
filter(None, map(_get_grad_fn_or_grad_acc, weights))
|
||||
)
|
||||
|
||||
reverse_edges_dict = construct_reverse_graph(stage_output_grad_fns)
|
||||
param_groups = get_param_groups(
|
||||
stage_input_grad_fns, weight_grad_fns, reverse_edges_dict
|
||||
)
|
||||
|
||||
handles = []
|
||||
for param_group in param_groups:
|
||||
for i, intermediate in enumerate(param_group["intermediates"]):
|
||||
|
||||
def get_hook(param_group, i):
|
||||
def hook(grad_inputs):
|
||||
if param_group.get("grads", None) is None:
|
||||
param_group["grads"] = [None] * len(
|
||||
param_group["intermediates"]
|
||||
)
|
||||
param_group["grads"][i] = grad_inputs
|
||||
|
||||
return hook
|
||||
|
||||
# These are always "split" nodes that we need to recompute, so
|
||||
# save their inputs.
|
||||
handle = intermediate.register_prehook(get_hook(param_group, i))
|
||||
handles.append(handle)
|
||||
|
||||
if output_grads is None:
|
||||
# In case this is the loss and there are no output_grads, then we just use 1s
|
||||
output_grads = [
|
||||
torch.ones_like(stage_output) for stage_output in stage_outputs_or_loss
|
||||
]
|
||||
|
||||
dinputs = _autograd_grad_for_inputs(
|
||||
stage_outputs_or_loss,
|
||||
input_values,
|
||||
output_grads,
|
||||
retain_graph=True,
|
||||
)
|
||||
|
||||
# Accumulate into .grad
|
||||
for inp, dinput in zip(input_values, dinputs):
|
||||
if isinstance(inp, torch.Tensor) and dinput is not None:
|
||||
if inp.grad is None:
|
||||
inp.grad = dinput
|
||||
else:
|
||||
inp.grad += dinput
|
||||
|
||||
# stage_outputs_or_loss are not used in backwards after this point, so we can safely remove it from the autograd graph
|
||||
# this allows autograd to clear up the graph dedicated for this tensor and free up significant memory
|
||||
for t in stage_outputs_or_loss:
|
||||
t.detach_()
|
||||
|
||||
# hooks are no longer necessary, clean up for consistency
|
||||
for handle in handles:
|
||||
handle.remove()
|
||||
|
||||
return dinputs, param_groups
|
||||
|
||||
|
||||
def stage_backward_weight(
|
||||
weights: Iterator[Parameter], param_groups: list[dict[str, Any]], retain_graph=False
|
||||
) -> tuple[torch.Tensor | None, ...]:
|
||||
# map weights to param_group_weights
|
||||
grad_acc_to_weight = {}
|
||||
weight_grads: list[torch.Tensor | None] = []
|
||||
for index, weight in enumerate(weights):
|
||||
grad_acc = _get_grad_fn_or_grad_acc(weight)
|
||||
grad_acc_to_weight[grad_acc] = weight, index
|
||||
weight_grads.append(weight.grad)
|
||||
|
||||
for param_group in param_groups:
|
||||
valid_edges = []
|
||||
valid_grad_outputs: list[torch.Tensor] = []
|
||||
|
||||
for grads_tuple, intermediate in zip(
|
||||
param_group["grads"], param_group["intermediates"]
|
||||
):
|
||||
for i, grad in enumerate(grads_tuple):
|
||||
if grad is not None:
|
||||
valid_edges.append(GradientEdge(intermediate, i))
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
valid_grad_outputs.append(grad)
|
||||
|
||||
# Break a reference cycle caused inside stage_backward_input->get_hook->hook
|
||||
# The summarized cycle is:
|
||||
# `hook` -> cell -> param_group -> intermediates -> `hook`
|
||||
# because we install the hook function onto each of the intermediate autograd nodes.
|
||||
# We need to keep intermediates alive up until backward_weight, but we can free it now.
|
||||
del param_group["intermediates"]
|
||||
|
||||
if valid_edges: # Only call autograd.grad if we have valid gradients
|
||||
# [NEW!] Able to pass a GradientEdge to autograd.grad as output
|
||||
weights_edges = tuple(GradientEdge(w, 0) for w in param_group["params"])
|
||||
dweights = torch.autograd.grad(
|
||||
valid_edges,
|
||||
weights_edges,
|
||||
grad_outputs=valid_grad_outputs,
|
||||
retain_graph=retain_graph,
|
||||
)
|
||||
|
||||
# release grad memory early after use
|
||||
del param_group["grads"]
|
||||
|
||||
for grad_acc, dw in zip(param_group["params"], dweights):
|
||||
weight, index = grad_acc_to_weight[grad_acc]
|
||||
if weight.grad is None:
|
||||
weight.grad = dw
|
||||
else:
|
||||
weight.grad += dw
|
||||
# return grads in the original order weights were provided in
|
||||
return tuple(weight_grads)
|
||||
|
||||
|
||||
def stage_backward(
|
||||
stage_output,
|
||||
output_grads,
|
||||
input_values,
|
||||
outputs_with_grads_idxs: list[int] | None = None, # deprecated, not used
|
||||
) -> tuple[torch.Tensor | None, ...]:
|
||||
"""
|
||||
This is a helper function to:
|
||||
1. compute the gradients for the stage inputs, and
|
||||
2. accumulate gradients for the stage module's parameters.
|
||||
|
||||
Given the input value(s) and the corresponding gradient for the output
|
||||
value(s), compute and accumulate gradients for all parameter values (leaves
|
||||
in the autograd trace) as well as return a list of the gradients for the
|
||||
input values
|
||||
"""
|
||||
if outputs_with_grads_idxs is not None:
|
||||
# Deprecated, not used in runtime calls, only exists in compiler
|
||||
stage_output = [stage_output[i] for i in outputs_with_grads_idxs]
|
||||
output_grads = [output_grads[i] for i in outputs_with_grads_idxs]
|
||||
|
||||
try:
|
||||
# stage_output may be a composite datatype like dict. Extract all individual
|
||||
# tensor values here
|
||||
stage_output_tensors: list[torch.Tensor] = []
|
||||
output_grad_tensors: list[torch.Tensor | None] = []
|
||||
|
||||
def extract_tensors_with_grads(
|
||||
output_val,
|
||||
grad_val,
|
||||
# Don't delete me- see [Note: ref cycle]
|
||||
extract_tensors_with_grads,
|
||||
):
|
||||
if isinstance(output_val, torch.Tensor):
|
||||
if not output_val.requires_grad and output_val.grad_fn is None:
|
||||
return
|
||||
if not isinstance(grad_val, (torch.Tensor, type(None))):
|
||||
raise AssertionError(
|
||||
f"Expected Tensor or None gradient but got {type(grad_val)}"
|
||||
)
|
||||
stage_output_tensors.append(output_val)
|
||||
output_grad_tensors.append(grad_val)
|
||||
elif isinstance(output_val, (tuple, list)):
|
||||
if grad_val is None:
|
||||
return
|
||||
if not isinstance(grad_val, (tuple, list)):
|
||||
raise AssertionError(
|
||||
f"grad_value expected to have type {type(output_val)} but got {type(grad_val)}"
|
||||
)
|
||||
if not len(output_val) == len(grad_val):
|
||||
raise AssertionError(
|
||||
f"Expected len(output_val) == len(grad_val), got {len(output_val)} != {len(grad_val)}"
|
||||
)
|
||||
for ov, gv in zip(output_val, grad_val):
|
||||
extract_tensors_with_grads(
|
||||
ov,
|
||||
gv,
|
||||
extract_tensors_with_grads,
|
||||
)
|
||||
elif isinstance(output_val, dict):
|
||||
if grad_val is None:
|
||||
return
|
||||
if not isinstance(grad_val, dict):
|
||||
raise AssertionError(f"Expected dict, got {type(grad_val)}")
|
||||
if not set(output_val.keys()) == set(grad_val.keys()):
|
||||
raise AssertionError(
|
||||
f"Expected keys {set(output_val.keys())}, got {set(grad_val.keys())}"
|
||||
)
|
||||
for k in output_val:
|
||||
extract_tensors_with_grads(
|
||||
output_val[k], grad_val[k], extract_tensors_with_grads
|
||||
)
|
||||
else:
|
||||
# Output is a non-tensor type; just ignore it
|
||||
pass
|
||||
|
||||
# Note: ref cycle
|
||||
# break a ref cycle that would keep tensors alive until GC runs
|
||||
# 1. extract_tensors_with_grads refers to a cell that holds refs to any vars defined in stage_backward
|
||||
# and used in extract_tensors_with_grads
|
||||
# 2. extract_tensors_with_grads referred to both stage_output_tensors, output_grad_tensors,
|
||||
# and to itself (extract_tensors_with_grads) since it makes a recursive call
|
||||
# 3. stage_output_tensors was kept alive by the above refcycle, and it holds activation tensors, which is bad
|
||||
# fix -> explicitly pass in the ref to the fn, so there is no gc cycle anymore
|
||||
extract_tensors_with_grads(
|
||||
stage_output, output_grads, extract_tensors_with_grads
|
||||
)
|
||||
|
||||
torch.autograd.backward(
|
||||
stage_output_tensors,
|
||||
grad_tensors=output_grad_tensors, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Extract gradients wrt the input values
|
||||
grad_inputs: list[torch.Tensor | None] = []
|
||||
for val in input_values:
|
||||
if isinstance(val, torch.Tensor):
|
||||
grad_inputs.append(val.grad)
|
||||
# Since gradients that will pass back to previous stages do not require gradient accumulation,
|
||||
# by decrementing the gradients' reference count at this point, the memory of gradients will be
|
||||
# returned to the allocator as soon as the next micro batch's get_bwd_send_ops comes and current
|
||||
# asynchronous send completes.
|
||||
# This prevents the gradients from persisting in GPU memory for the entire duration of step_microbatches
|
||||
# until clear_runtime_states() is called.
|
||||
val.grad = None
|
||||
else:
|
||||
grad_inputs.append(None)
|
||||
|
||||
# Alternative impl: `torch.autograd.grad`.
|
||||
# Note that `torch.autograd.grad` will not accumulate gradients into the
|
||||
# model's parameters.
|
||||
"""
|
||||
inputs_with_grad = []
|
||||
for val in input_values:
|
||||
if isinstance(val, torch.Tensor) and val.requires_grad:
|
||||
inputs_with_grad.append(val)
|
||||
|
||||
grad_inputs = torch.autograd.grad(
|
||||
stage_output_tensors, inputs_with_grad, output_grad_tensors, # type: ignore[arg-type]
|
||||
)
|
||||
"""
|
||||
|
||||
except Exception as e:
|
||||
exc_msg = f"""
|
||||
Failed to run stage backward:
|
||||
Stage output: {map_debug_info(stage_output)}
|
||||
Output gradient: {map_debug_info(output_grads)}
|
||||
Input: {map_debug_info(input_values)}
|
||||
"""
|
||||
raise RuntimeError(exc_msg) from e
|
||||
|
||||
return tuple(grad_inputs)
|
||||
|
||||
|
||||
# TODO: handling requires_grad=False dynamically. Can we analyze this during initial
|
||||
# IR emission?
|
||||
def _null_coalesce_accumulate(lhs, rhs):
|
||||
"""
|
||||
Coalesce two values, even if one of them is null, returning the non-null
|
||||
value.
|
||||
"""
|
||||
if lhs is None:
|
||||
return rhs
|
||||
elif rhs is None:
|
||||
return lhs
|
||||
else:
|
||||
return torch.add(lhs, rhs)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
import torch
|
||||
from torch.fx.node import Argument
|
||||
|
||||
|
||||
def friendly_debug_info(v: object) -> Argument:
|
||||
"""
|
||||
Helper function to print out debug info in a friendly way.
|
||||
"""
|
||||
if isinstance(v, torch.Tensor):
|
||||
return f"Tensor({v.shape}, grad={v.requires_grad}, dtype={v.dtype})"
|
||||
else:
|
||||
return str(v)
|
||||
|
||||
|
||||
def map_debug_info(a: Argument) -> Argument:
|
||||
"""
|
||||
Helper function to apply `friendly_debug_info` to items in `a`.
|
||||
`a` may be a list, tuple, or dict.
|
||||
"""
|
||||
return torch.fx.node.map_aggregate(a, friendly_debug_info)
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
"""
|
||||
This visualizer requires matplotlib to be installed.
|
||||
|
||||
Example usage:
|
||||
|
||||
ops = get_schedule_ops("InterleavedZeroBubble", 4, 8)
|
||||
visualize_schedule(ops, "test.png")
|
||||
"""
|
||||
|
||||
import collections
|
||||
from typing import NamedTuple
|
||||
from unittest import mock
|
||||
|
||||
from torch.distributed.pipelining.schedules import (
|
||||
_Action,
|
||||
_ComputationType,
|
||||
_PipelineSchedule,
|
||||
_PipelineScheduleRuntime,
|
||||
get_schedule_class,
|
||||
PipelineScheduleMulti,
|
||||
PipelineScheduleSingle,
|
||||
)
|
||||
from torch.distributed.pipelining.stage import PipelineStage
|
||||
|
||||
|
||||
class OpKey(NamedTuple):
|
||||
stage_index: int
|
||||
computation_type: _ComputationType
|
||||
microbatch_index: int
|
||||
|
||||
|
||||
def get_schedule_ops(
|
||||
schedule: str | type[_PipelineSchedule],
|
||||
pp_degree: int,
|
||||
num_microbatches: int,
|
||||
num_stages_per_rank: int | None = None,
|
||||
add_spacing: bool = False,
|
||||
with_comms: bool = False,
|
||||
) -> list[list[_Action | None]]:
|
||||
"""
|
||||
Get all actions for a given schedule, pp_degree, and num_microbatches. The actions are returned in a list of lists
|
||||
where each inner list represents a rank and each element in the inner list represents an action.
|
||||
|
||||
The schedule can be specified as a string which is passed into get_schedule_class() or a _PipelineSchedule instance.
|
||||
"""
|
||||
if add_spacing and with_comms:
|
||||
raise ValueError("Cannot add spacing and view comms at the same time")
|
||||
|
||||
if isinstance(schedule, str):
|
||||
schedule_class = get_schedule_class(schedule)
|
||||
elif issubclass(schedule, _PipelineSchedule):
|
||||
schedule_class = schedule
|
||||
else:
|
||||
raise ValueError(f"Invalid schedule: {schedule}")
|
||||
|
||||
# Create a mock of the PipelineStage class
|
||||
mock_pipeline_stage = mock.create_autospec(PipelineStage, instance=True)
|
||||
# Set the return values for group_rank and group_size methods
|
||||
mock_pipeline_stage.group_rank = 0
|
||||
mock_pipeline_stage.group_size = pp_degree
|
||||
mock_pipeline_stage.submod = None
|
||||
|
||||
# Check num_stages_per_rank is valid
|
||||
if issubclass(schedule_class, PipelineScheduleSingle):
|
||||
if num_stages_per_rank is None:
|
||||
num_stages_per_rank = 1
|
||||
if not num_stages_per_rank == 1:
|
||||
raise AssertionError(
|
||||
f"Expected num_stages_per_rank to be 1, got {num_stages_per_rank}"
|
||||
)
|
||||
stages = mock_pipeline_stage
|
||||
stages.num_stages = num_stages_per_rank * pp_degree
|
||||
elif issubclass(schedule_class, PipelineScheduleMulti):
|
||||
if num_stages_per_rank is None:
|
||||
num_stages_per_rank = 2
|
||||
if not num_stages_per_rank >= 2:
|
||||
raise AssertionError(
|
||||
f"Expected num_stages_per_rank >= 2, got {num_stages_per_rank}"
|
||||
)
|
||||
stages = [mock_pipeline_stage for _ in range(num_stages_per_rank)]
|
||||
for stage in stages:
|
||||
stage.num_stages = num_stages_per_rank * pp_degree
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid schedule: {schedule_class}")
|
||||
|
||||
# Instantiate the schedule class
|
||||
# pyrefly: ignore [bad-argument-type]
|
||||
schedule_instance = schedule_class(stages, num_microbatches)
|
||||
if schedule_instance.pipeline_order is None:
|
||||
raise AssertionError("Expected pipeline_order to not be None")
|
||||
|
||||
# Convert to List[List[_Action]]
|
||||
all_actions: list[list[_Action | None]] = []
|
||||
if with_comms:
|
||||
runtime = _PipelineScheduleRuntime(stages, num_microbatches)
|
||||
runtime._prepare_schedule_with_comms(schedule_instance.pipeline_order)
|
||||
for rank in range(pp_degree):
|
||||
all_actions.append(list(runtime.pipeline_order_with_comms[rank]))
|
||||
else:
|
||||
for rank in range(pp_degree):
|
||||
all_actions.append(schedule_instance.pipeline_order[rank])
|
||||
|
||||
# Add spacing
|
||||
if add_spacing:
|
||||
# remove all Nones, then respace
|
||||
# TODO: later we can change this at the schedule creation level to not use Nones
|
||||
all_actions = [
|
||||
[action for action in rank if action is not None] for rank in all_actions
|
||||
]
|
||||
all_actions = add_schedule_op_spacing(all_actions)
|
||||
|
||||
# Return the pipeline order
|
||||
return all_actions
|
||||
|
||||
|
||||
class _ComputationTypeVisual:
|
||||
def __init__(
|
||||
self,
|
||||
color: str,
|
||||
text: str = "",
|
||||
width: int = 1,
|
||||
):
|
||||
self.color = color
|
||||
self.width = width
|
||||
self.text = text
|
||||
|
||||
|
||||
# Update the mapping to use _ComputationTypeVisual instances
|
||||
action_type_to_color_mapping = {
|
||||
_ComputationType.FORWARD: _ComputationTypeVisual("blue", "Forward"),
|
||||
_ComputationType.BACKWARD_INPUT: _ComputationTypeVisual("teal", "Backward Input"),
|
||||
_ComputationType.BACKWARD_WEIGHT: _ComputationTypeVisual(
|
||||
"green", "Backward Weight"
|
||||
),
|
||||
_ComputationType.FULL_BACKWARD: _ComputationTypeVisual(
|
||||
"orange", "Full Backward", 2
|
||||
),
|
||||
_ComputationType.OVERLAP_F_B: _ComputationTypeVisual("purple", "Overlap F+B", 3),
|
||||
}
|
||||
|
||||
|
||||
def add_schedule_op_spacing(
|
||||
schedule: list[list[_Action | None]],
|
||||
) -> list[list[_Action | None]]:
|
||||
"""
|
||||
Add spacing to the schedule based on dependencies between ranks.
|
||||
|
||||
Before adding an operation to the list, this function checks if there are
|
||||
dependencies from other ranks. If there are dependencies (other ranks have
|
||||
not finished processing the required microbatch), it adds None instead.
|
||||
|
||||
For example, Forward microbatch 0 on rank 1 depends on rank 0 processing
|
||||
Forward microbatch 0 first.
|
||||
|
||||
Args:
|
||||
schedule: The original schedule as a list of lists where each inner list
|
||||
represents a rank and each element represents an action.
|
||||
|
||||
Returns:
|
||||
A new schedule with proper spacing based on dependencies.
|
||||
"""
|
||||
if not schedule:
|
||||
return schedule
|
||||
|
||||
num_stages = (
|
||||
max(
|
||||
action.stage_index
|
||||
for rank_actions in schedule
|
||||
for action in rank_actions
|
||||
if action is not None
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
|
||||
num_ranks = len(schedule)
|
||||
spaced_schedule: list[list[_Action | None]] = [[] for _ in range(num_ranks)]
|
||||
rank_ops = [collections.deque(ops) for ops in schedule]
|
||||
|
||||
# Track completion times: (stage_index, action_type, microbatch_index) -> completion_time
|
||||
scheduled_ops: dict[OpKey, int] = {}
|
||||
|
||||
def is_dependency_ready(dependency_key: OpKey, timestep: int) -> bool:
|
||||
"""Check if a dependency operation has completed by the given timestep."""
|
||||
return (
|
||||
dependency_key in scheduled_ops
|
||||
and timestep >= scheduled_ops[dependency_key]
|
||||
)
|
||||
|
||||
def get_dependencies(action: _Action) -> list[OpKey]:
|
||||
"""Get the list of dependencies for an action."""
|
||||
stage_idx = action.stage_index
|
||||
comp_type = action.computation_type
|
||||
mb_idx = action.microbatch_index
|
||||
|
||||
# Ensure mb_idx is not None for dependency tracking
|
||||
if mb_idx is None:
|
||||
raise AssertionError(f"Action {action} has None microbatch_index")
|
||||
|
||||
# First stage forward has no dependencies
|
||||
if stage_idx == 0 and comp_type == _ComputationType.FORWARD:
|
||||
return []
|
||||
|
||||
# Last stage backward depends on forward from previous stage
|
||||
if stage_idx == num_stages - 1 and comp_type in (
|
||||
_ComputationType.FULL_BACKWARD,
|
||||
_ComputationType.BACKWARD_INPUT,
|
||||
):
|
||||
return [OpKey(stage_idx - 1, _ComputationType.FORWARD, mb_idx)]
|
||||
|
||||
# Forward depends on previous stage forward
|
||||
if comp_type == _ComputationType.FORWARD:
|
||||
return [OpKey(stage_idx - 1, _ComputationType.FORWARD, mb_idx)]
|
||||
|
||||
# Backward depends on next stage backward
|
||||
if comp_type in (
|
||||
_ComputationType.FULL_BACKWARD,
|
||||
_ComputationType.BACKWARD_INPUT,
|
||||
):
|
||||
return [
|
||||
OpKey(stage_idx + 1, _ComputationType.FULL_BACKWARD, mb_idx),
|
||||
OpKey(stage_idx + 1, _ComputationType.BACKWARD_INPUT, mb_idx),
|
||||
]
|
||||
|
||||
# Weight backward depends on input backward
|
||||
if comp_type == _ComputationType.BACKWARD_WEIGHT:
|
||||
return [OpKey(stage_idx, _ComputationType.BACKWARD_INPUT, mb_idx)]
|
||||
|
||||
raise RuntimeError(f"Unknown computation type: {comp_type}")
|
||||
|
||||
def is_action_ready(action: _Action, timestep: int) -> bool:
|
||||
"""Check if an action is ready to be scheduled at the given timestep."""
|
||||
# For OR dependencies (like backward), check if any dependency is satisfied
|
||||
if action.computation_type in (
|
||||
_ComputationType.FULL_BACKWARD,
|
||||
_ComputationType.BACKWARD_INPUT,
|
||||
_ComputationType.BACKWARD_WEIGHT,
|
||||
):
|
||||
dependencies = get_dependencies(action)
|
||||
return any(is_dependency_ready(dep, timestep) for dep in dependencies)
|
||||
# For AND dependencies, all must be satisfied
|
||||
elif action.computation_type == _ComputationType.FORWARD:
|
||||
dependencies = get_dependencies(action)
|
||||
return all(is_dependency_ready(dep, timestep) for dep in dependencies)
|
||||
elif action.computation_type == _ComputationType.OVERLAP_F_B:
|
||||
if action.sub_actions is None:
|
||||
raise AssertionError(
|
||||
f"OVERLAP_F_B action {action} has None sub_actions"
|
||||
)
|
||||
dep_list: list[bool] = []
|
||||
for sub_action in action.sub_actions:
|
||||
dep_list.append(is_action_ready(sub_action, timestep))
|
||||
return all(dep_list)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown computation type: {action.computation_type}")
|
||||
|
||||
def schedule_action(action: _Action, rank: int, timestep: int) -> int:
|
||||
"""Schedule an action and return completion time."""
|
||||
spaced_schedule[rank].append(action)
|
||||
comp_type = action.computation_type
|
||||
comp_time = action_type_to_color_mapping[comp_type].width
|
||||
completion_time = timestep + comp_time
|
||||
|
||||
if comp_type == _ComputationType.OVERLAP_F_B:
|
||||
# For overlap actions, schedule each sub-action with cumulative timing
|
||||
if action.sub_actions is None:
|
||||
raise AssertionError(
|
||||
f"OVERLAP_F_B action {action} has None sub_actions"
|
||||
)
|
||||
cumulative_time = 0
|
||||
for sub_action in action.sub_actions:
|
||||
if sub_action.microbatch_index is None:
|
||||
raise AssertionError(
|
||||
f"Sub-action {sub_action} has None microbatch_index"
|
||||
)
|
||||
sub_comp_time = action_type_to_color_mapping[
|
||||
sub_action.computation_type
|
||||
].width
|
||||
cumulative_time += sub_comp_time
|
||||
scheduled_ops[
|
||||
OpKey(
|
||||
sub_action.stage_index,
|
||||
sub_action.computation_type,
|
||||
sub_action.microbatch_index,
|
||||
)
|
||||
] = timestep + cumulative_time
|
||||
else:
|
||||
if action.microbatch_index is None:
|
||||
raise AssertionError(f"Action {action} has None microbatch_index")
|
||||
scheduled_ops[
|
||||
OpKey(action.stage_index, comp_type, action.microbatch_index)
|
||||
] = completion_time
|
||||
|
||||
return completion_time
|
||||
|
||||
# Main scheduling loop
|
||||
current_timestep = 0
|
||||
timesteps_without_progress = 0
|
||||
rank_completion_times = dict.fromkeys(range(num_ranks), 0)
|
||||
while rank_ops:
|
||||
print(f"Current timestep: {current_timestep}")
|
||||
# Process all operations during timestep until we run out of ready operations
|
||||
for rank, op_queue in enumerate(rank_ops):
|
||||
if not op_queue:
|
||||
continue
|
||||
|
||||
op_queue = rank_ops[rank]
|
||||
action = op_queue[0]
|
||||
print(f"Rank: {rank}, {action=}")
|
||||
if action is None:
|
||||
spaced_schedule[rank].append(None)
|
||||
op_queue.popleft()
|
||||
timesteps_without_progress = 0
|
||||
elif current_timestep >= rank_completion_times[rank] and is_action_ready(
|
||||
action, current_timestep
|
||||
):
|
||||
rank_completion_times[rank] = schedule_action(
|
||||
action, rank, current_timestep
|
||||
)
|
||||
op_queue.popleft()
|
||||
timesteps_without_progress = 0
|
||||
|
||||
# Add None for ranks that are waiting
|
||||
for rank in range(num_ranks):
|
||||
if current_timestep >= rank_completion_times[rank]:
|
||||
spaced_schedule[rank].append(None)
|
||||
|
||||
# Remove empty queues and advance timestep
|
||||
rank_ops = [op_queue for op_queue in rank_ops if op_queue]
|
||||
current_timestep += 1
|
||||
timesteps_without_progress += 1
|
||||
|
||||
if timesteps_without_progress > max(
|
||||
visual.width for visual in action_type_to_color_mapping.values()
|
||||
):
|
||||
raise RuntimeError("No progress made in scheduling - possible deadlock")
|
||||
|
||||
return spaced_schedule
|
||||
|
||||
|
||||
def visualize_schedule(
|
||||
schedule: list[list[_Action | None]],
|
||||
filename: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Visualize the schedule using matplotlib.
|
||||
The schedule is a list of lists where each inner list represents a rank and each element in the inner list represents an action.
|
||||
The actions are represented as rectangles with different colors based on their computation type.
|
||||
The filename is optional and if provided, the plot will be saved to that file.
|
||||
|
||||
Args:
|
||||
schedule: The schedule to visualize.
|
||||
filename: The filename to save the plot to. If not provided, the plot will be displayed.
|
||||
add_schedule_spacing: If True, add spacing to the schedule based on dependencies between ranks.
|
||||
|
||||
"""
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import Rectangle
|
||||
|
||||
plt.rcParams["font.family"] = (
|
||||
"DejaVu Sans" # or any other font available on your system
|
||||
)
|
||||
num_ranks = len(schedule)
|
||||
max_actions = max(len(rank) for rank in schedule)
|
||||
|
||||
# Increase the figure size to provide more space for the legend
|
||||
fig, ax = plt.subplots(figsize=(max_actions + 2, num_ranks + 2))
|
||||
max_draw_position = -1
|
||||
# Calculate dynamic font size based on figure size
|
||||
font_size = min(max_actions, num_ranks) + 4
|
||||
used_computation = set()
|
||||
for rank_idx, actions in enumerate(schedule):
|
||||
draw_position = 0 # Initialize drawing position for each rank
|
||||
for action in actions:
|
||||
if action is not None:
|
||||
comp_type_color = action_type_to_color_mapping.get(
|
||||
action.computation_type, _ComputationTypeVisual("black")
|
||||
)
|
||||
used_computation.add(action.computation_type)
|
||||
color = comp_type_color.color
|
||||
width = comp_type_color.width
|
||||
|
||||
# Check if action has sub_actions to determine styling
|
||||
if action.sub_actions is not None:
|
||||
linewidth = 2 # Thicker border for compound actions
|
||||
text_weight = "normal" # Bold text for compound actions
|
||||
else:
|
||||
linewidth = 1 # Default linewidth for regular actions
|
||||
text_weight = "normal" # Default text weight
|
||||
|
||||
# Draw the rectangle to represent the action duration
|
||||
rect = Rectangle(
|
||||
(draw_position, num_ranks - rank_idx - 1),
|
||||
width,
|
||||
1,
|
||||
facecolor=color,
|
||||
edgecolor="black",
|
||||
linewidth=linewidth,
|
||||
)
|
||||
ax.add_patch(rect)
|
||||
|
||||
# Draw the text centered within the rectangle
|
||||
ax.text(
|
||||
draw_position + width / 2,
|
||||
num_ranks - rank_idx - 1 + 0.5,
|
||||
str(action),
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=font_size,
|
||||
color="white",
|
||||
weight=text_weight,
|
||||
)
|
||||
|
||||
draw_position += width
|
||||
else:
|
||||
draw_position += 1 # Move to the next
|
||||
max_draw_position = max(max_draw_position, draw_position)
|
||||
ax.set_xlim(-0.5, max_draw_position + 1)
|
||||
ax.set_ylim(-0.5, num_ranks + 0.5) # Add extra space at the top
|
||||
# Set y-ticks to be in the middle of each rank's row
|
||||
ax.set_yticks([num_ranks - rank_idx - 0.5 for rank_idx in range(num_ranks)])
|
||||
ax.set_yticklabels([f"Rank {i}" for i in range(num_ranks)], fontsize=font_size)
|
||||
ax.set_xticklabels([])
|
||||
|
||||
# Remove grid lines and ticks
|
||||
ax.grid(False)
|
||||
# Add legend with larger font size
|
||||
legend_elements = [
|
||||
Rectangle(
|
||||
(0, 0),
|
||||
1,
|
||||
1,
|
||||
facecolor=action_type_to_color_mapping[comp_type].color,
|
||||
edgecolor="black",
|
||||
label=action_type_to_color_mapping[comp_type].text,
|
||||
)
|
||||
for comp_type in used_computation
|
||||
]
|
||||
ax.legend(handles=legend_elements, loc="upper right", fontsize=font_size)
|
||||
# Save to file if filename is provided, otherwise display the plot
|
||||
if filename:
|
||||
plt.savefig(filename, bbox_inches="tight")
|
||||
else:
|
||||
plt.show()
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
from collections import defaultdict
|
||||
|
||||
import torch
|
||||
from torch.export.unflatten import _ModuleFrame, _SubmoduleEntry
|
||||
|
||||
|
||||
def _outline_submodules(orig_graph: torch.fx.Graph) -> torch.fx.GraphModule:
|
||||
# Create an empty GraphModule to hold the outlined modules
|
||||
new_module = torch.fx.GraphModule(torch.nn.Module(), torch.fx.Graph())
|
||||
seen_nodes: dict[str, torch.fx.Node] = {}
|
||||
seen_modules: dict[int, list[_SubmoduleEntry]] = defaultdict(list)
|
||||
seen_attrs: dict[str, set[str]] = defaultdict(set)
|
||||
created_modules: dict[str, torch.nn.Module] = {}
|
||||
_ModuleFrame(
|
||||
orig_graph,
|
||||
tuple(orig_graph.nodes),
|
||||
seen_nodes,
|
||||
seen_modules,
|
||||
seen_attrs,
|
||||
created_modules,
|
||||
None,
|
||||
[("", None, 0)],
|
||||
"",
|
||||
{},
|
||||
module=new_module,
|
||||
).run_outer()
|
||||
new_module.graph.lint()
|
||||
new_module.recompile()
|
||||
return new_module
|
||||
@@ -0,0 +1,938 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import cast, Literal, overload, Protocol, TYPE_CHECKING, TypeAlias
|
||||
|
||||
import torch
|
||||
from torch import fx
|
||||
from torch.distributed._mesh_layout import _MeshLayout
|
||||
from torch.distributed.tensor import DTensor
|
||||
from torch.utils._pytree import tree_flatten, tree_unflatten
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from torch.distributed.device_mesh import DeviceMesh
|
||||
from torch.distributed.tensor.placement_types import Placement
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GetMeshCallback(Protocol):
|
||||
"""Callback to create/retrieve a DeviceMesh from its cache key components."""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
mesh_dim_names: tuple[str, ...],
|
||||
mesh_layout: _MeshLayout | None,
|
||||
) -> DeviceMesh: ...
|
||||
|
||||
|
||||
# Key for mesh cache: (mesh_dim_names, mesh_layout)
|
||||
# mesh_layout is the _MeshLayout object containing shape and stride (not actual ranks).
|
||||
# This uniquely identifies a mesh within the same "universe" where all stages share
|
||||
# the same rank tensor.
|
||||
MeshCacheKey: TypeAlias = tuple[tuple[str, ...], _MeshLayout | None]
|
||||
|
||||
|
||||
class PipeliningMetadataError(RuntimeError):
|
||||
"""Raised on metadata mismatches during pipeline communication."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TensorMeta:
|
||||
"""Tensor metadata for recv buffer allocation and validation.
|
||||
|
||||
For plain tensors, these are the tensor's actual attributes.
|
||||
For DTensors, these are LOCAL shard attributes; global attributes
|
||||
are stored in :class:`_DTensorMeta`.
|
||||
"""
|
||||
|
||||
shape: torch.Size
|
||||
stride: tuple[int, ...]
|
||||
dtype: torch.dtype
|
||||
requires_grad: bool
|
||||
|
||||
@staticmethod
|
||||
def from_tensor(tensor: torch.Tensor) -> _TensorMeta:
|
||||
"""Create metadata from a plain tensor.
|
||||
|
||||
Args:
|
||||
tensor: A plain ``torch.Tensor`` (not DTensor).
|
||||
|
||||
Returns:
|
||||
Metadata capturing shape, stride, dtype, and requires_grad.
|
||||
|
||||
Raises:
|
||||
TypeError: If ``tensor`` is a DTensor.
|
||||
"""
|
||||
if isinstance(tensor, DTensor):
|
||||
raise PipeliningMetadataError(
|
||||
"Expected plain tensor, got DTensor. Use _DTensorMeta.from_dtensor instead."
|
||||
)
|
||||
return _TensorMeta(
|
||||
shape=tensor.shape,
|
||||
stride=tensor.stride(),
|
||||
dtype=tensor.dtype,
|
||||
requires_grad=tensor.requires_grad,
|
||||
)
|
||||
|
||||
def to_tensor(self, device: torch.device | str) -> torch.Tensor:
|
||||
"""Reconstruct a tensor on ``device`` from this metadata.
|
||||
|
||||
Args:
|
||||
device: Target device for the tensor.
|
||||
|
||||
Returns:
|
||||
An empty strided tensor on ``device``.
|
||||
"""
|
||||
t = _make_tensor_from_meta(self, device)
|
||||
t.requires_grad_(self.requires_grad)
|
||||
return t
|
||||
|
||||
def get_diff(self, other: _TensorMeta) -> list[str]:
|
||||
"""Return field-by-field differences with ``other``.
|
||||
|
||||
Args:
|
||||
other: Metadata to compare against.
|
||||
|
||||
Returns:
|
||||
List of human-readable difference strings (empty if equal).
|
||||
"""
|
||||
if self == other:
|
||||
return []
|
||||
|
||||
diffs = []
|
||||
if self.shape != other.shape:
|
||||
diffs.append(f"shape mismatch: {self.shape} vs {other.shape}")
|
||||
if self.stride != other.stride:
|
||||
diffs.append(f"stride mismatch: {self.stride} vs {other.stride}")
|
||||
if self.dtype != other.dtype:
|
||||
diffs.append(f"dtype mismatch: {self.dtype} vs {other.dtype}")
|
||||
# requires_grad is intentionally excluded: it is a runtime concern
|
||||
# determined by has_backward and grad context, not a metadata invariant.
|
||||
return diffs
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DTensorMeta(_TensorMeta):
|
||||
"""DTensor metadata extending :class:`_TensorMeta` with distribution info.
|
||||
|
||||
Inherited fields (shape, stride, etc.) are LOCAL shard attributes.
|
||||
Additional fields capture global shape and placement information
|
||||
needed to reconstruct a :class:`DTensor` via ``DTensor.from_local()``.
|
||||
|
||||
The :class:`DeviceMesh` is **not** stored (not serializable for P2P);
|
||||
it is looked up from :class:`_MeshCache` using
|
||||
``(mesh_dim_names, mesh_layout)`` as the key.
|
||||
"""
|
||||
|
||||
# Global DTensor properties (for reconstruction)
|
||||
global_shape: torch.Size = field(default_factory=lambda: torch.Size([]))
|
||||
global_stride: tuple[int, ...] = field(default=())
|
||||
|
||||
# DTensor distribution properties
|
||||
placements: tuple[Placement, ...] = field(
|
||||
default=()
|
||||
) # e.g., (Shard(0), Replicate())
|
||||
|
||||
# Mesh identification - used to look up the correct DeviceMesh from cache
|
||||
mesh_dim_names: tuple[str, ...] = field(default=()) # e.g., ("tp",) or ("dp", "tp")
|
||||
mesh_layout: _MeshLayout | None = field(
|
||||
default=None
|
||||
) # _MeshLayout with shape/stride - uniquely identifies mesh within the same universe
|
||||
|
||||
@staticmethod
|
||||
def from_dtensor(dtensor: DTensor) -> _DTensorMeta:
|
||||
"""Create metadata from a DTensor.
|
||||
|
||||
Args:
|
||||
dtensor: The DTensor to extract metadata from.
|
||||
|
||||
Returns:
|
||||
Metadata capturing both local and global attributes.
|
||||
"""
|
||||
device_mesh = dtensor.device_mesh
|
||||
|
||||
return _DTensorMeta(
|
||||
# Local tensor attributes (for recv buffer allocation)
|
||||
shape=dtensor._local_tensor.shape,
|
||||
stride=dtensor._local_tensor.stride(),
|
||||
dtype=dtensor.dtype,
|
||||
requires_grad=dtensor.requires_grad,
|
||||
# Global DTensor attributes (for reconstruction)
|
||||
global_shape=dtensor.shape,
|
||||
global_stride=dtensor.stride(),
|
||||
# Distribution info
|
||||
placements=dtensor._spec.placements,
|
||||
mesh_dim_names=(
|
||||
tuple(device_mesh.mesh_dim_names) if device_mesh.mesh_dim_names else ()
|
||||
),
|
||||
mesh_layout=device_mesh._layout,
|
||||
)
|
||||
|
||||
@property
|
||||
def mesh_cache_key(self) -> MeshCacheKey:
|
||||
"""Cache key ``(mesh_dim_names, mesh_layout)`` for mesh lookup."""
|
||||
return (self.mesh_dim_names, self.mesh_layout)
|
||||
|
||||
def to_dtensor(self, device: torch.device | str, mesh: DeviceMesh) -> DTensor:
|
||||
"""Reconstruct a DTensor on ``device`` with placements.
|
||||
|
||||
Args:
|
||||
device: Target device for the local tensor.
|
||||
mesh: The ``DeviceMesh`` to attach.
|
||||
|
||||
Returns:
|
||||
A DTensor on ``device``.
|
||||
"""
|
||||
local_tensor = _make_tensor_from_meta(self, device)
|
||||
# Set requires_grad after from_local() so that the from_local
|
||||
# operation itself is not recorded in the autograd graph.
|
||||
return cast(
|
||||
DTensor,
|
||||
DTensor.from_local(
|
||||
local_tensor,
|
||||
device_mesh=mesh,
|
||||
placements=self.placements,
|
||||
shape=self.global_shape,
|
||||
stride=self.global_stride,
|
||||
run_check=False,
|
||||
).requires_grad_(self.requires_grad),
|
||||
)
|
||||
|
||||
def get_diff(self, other: _TensorMeta) -> list[str]:
|
||||
"""Return field-by-field differences, including DTensor-specific fields.
|
||||
|
||||
Args:
|
||||
other: Metadata to compare against.
|
||||
|
||||
Returns:
|
||||
List of human-readable difference strings (empty if equal).
|
||||
"""
|
||||
if self == other:
|
||||
return []
|
||||
|
||||
# Get base class differences (compares local shape/stride/dtype/requires_grad)
|
||||
# NOTE: Use explicit class call instead of super() because
|
||||
# @dataclass(slots=True) on both parent and child can break super().
|
||||
diffs = _TensorMeta.get_diff(self, other)
|
||||
|
||||
# Add DTensor-specific comparisons if other is also _DTensorMeta
|
||||
if isinstance(other, _DTensorMeta):
|
||||
if self.global_shape != other.global_shape:
|
||||
diffs.append(
|
||||
f"global_shape mismatch: {self.global_shape} vs {other.global_shape}"
|
||||
)
|
||||
if self.global_stride != other.global_stride:
|
||||
diffs.append(
|
||||
f"global_stride mismatch: {self.global_stride} vs {other.global_stride}"
|
||||
)
|
||||
if self.placements != other.placements:
|
||||
diffs.append(
|
||||
f"placements mismatch: {self.placements} vs {other.placements}"
|
||||
)
|
||||
if self.mesh_dim_names != other.mesh_dim_names:
|
||||
diffs.append(
|
||||
f"mesh_dim_names mismatch: {self.mesh_dim_names} vs {other.mesh_dim_names}"
|
||||
)
|
||||
if self.mesh_layout != other.mesh_layout:
|
||||
diffs.append(
|
||||
f"mesh_layout mismatch: {self.mesh_layout} vs {other.mesh_layout}"
|
||||
)
|
||||
else:
|
||||
diffs.append("type: _DTensorMeta vs _TensorMeta")
|
||||
|
||||
return diffs
|
||||
|
||||
|
||||
# Type alias for union of tensor metadata types
|
||||
TensorMeta: TypeAlias = _TensorMeta | _DTensorMeta
|
||||
|
||||
|
||||
# Not frozen: fields are populated incrementally during forward and
|
||||
# backward metadata inference or from user provided static metadata
|
||||
@dataclass(slots=True)
|
||||
class _StageMeta:
|
||||
"""Consolidated tensor metadata for a pipeline stage's forward and backward passes."""
|
||||
|
||||
inputs: tuple[TensorMeta, ...] | None = None
|
||||
outputs: tuple[TensorMeta, ...] | None = None
|
||||
input_grads: tuple[TensorMeta | None, ...] | None = None
|
||||
output_grads: tuple[TensorMeta | None, ...] | None = None
|
||||
|
||||
def has_any(self) -> bool:
|
||||
"""Check if any metadata field is populated."""
|
||||
return any(
|
||||
v is not None
|
||||
for v in [self.inputs, self.outputs, self.input_grads, self.output_grads]
|
||||
)
|
||||
|
||||
def has_dtensors(self) -> bool:
|
||||
"""Check if any input/output metadata is DTensor type."""
|
||||
for metas in [self.inputs, self.outputs]:
|
||||
if metas and any(isinstance(m, _DTensorMeta) for m in metas if m):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_complete_for_forward(self) -> bool:
|
||||
"""Check if forward metadata is fully populated."""
|
||||
return self.inputs is not None and self.outputs is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _StageForwardMeta:
|
||||
"""Forward metadata transmitted from stage *i* to stage *i+1* during inference."""
|
||||
|
||||
forward_metas: tuple[TensorMeta, ...] # Stage i's outputs → Stage i+1's inputs
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _StageBackwardMeta:
|
||||
"""Backward metadata transmitted from stage *i* to stage *i-1* during inference.
|
||||
|
||||
Gradient placements may differ from forward activations
|
||||
(e.g., ``Replicate`` → ``Partial``).
|
||||
"""
|
||||
|
||||
backward_metas: tuple[
|
||||
TensorMeta | None, ...
|
||||
] # Stage i's input_grads → Stage i-1's output_grads
|
||||
|
||||
|
||||
def _make_tensor_from_meta(
|
||||
meta: _TensorMeta,
|
||||
device: torch.device | str,
|
||||
) -> torch.Tensor:
|
||||
"""Create a tensor from metadata.
|
||||
|
||||
Args:
|
||||
meta: Metadata with shape, stride, and dtype.
|
||||
device: Target device for the tensor.
|
||||
|
||||
Returns:
|
||||
Empty tensor preserving the exact memory layout.
|
||||
"""
|
||||
return torch.empty_strided(
|
||||
size=meta.shape,
|
||||
stride=meta.stride,
|
||||
dtype=meta.dtype,
|
||||
device=device,
|
||||
)
|
||||
|
||||
|
||||
def _derive_grad_metas(
|
||||
tensor_metas: tuple[TensorMeta, ...],
|
||||
) -> tuple[_TensorMeta | None, ...]:
|
||||
"""Derive gradient metadata from tensor metadata.
|
||||
|
||||
Returns metadata with the same shape/stride/dtype but ``requires_grad=False``.
|
||||
Entries where the source has ``requires_grad=False`` become ``None``.
|
||||
"""
|
||||
return tuple(
|
||||
_TensorMeta(shape=m.shape, stride=m.stride, dtype=m.dtype, requires_grad=False)
|
||||
if m.requires_grad
|
||||
else None
|
||||
for m in tensor_metas
|
||||
)
|
||||
|
||||
|
||||
class _MeshCache:
|
||||
"""Cache for :class:`DeviceMesh` objects keyed by ``(mesh_dim_names, mesh_layout)``.
|
||||
|
||||
Assumes all pipeline stages share the same rank tensor (true for
|
||||
TorchTitan-style frameworks where meshes derive from a common world).
|
||||
"""
|
||||
|
||||
def __init__(self, get_mesh_cb: GetMeshCallback | None = None) -> None:
|
||||
self._cache: dict[MeshCacheKey, DeviceMesh] = {}
|
||||
self._get_mesh_cb = get_mesh_cb
|
||||
|
||||
def get_mesh(self, key: MeshCacheKey) -> DeviceMesh:
|
||||
"""Return a cached mesh, or create one via the callback.
|
||||
|
||||
Args:
|
||||
key: Cache key ``(mesh_dim_names, mesh_layout)``.
|
||||
|
||||
Returns:
|
||||
The ``DeviceMesh``.
|
||||
|
||||
Raises:
|
||||
PipeliningMetadataError: If not cached and no callback provided.
|
||||
"""
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
mesh_dim_names, mesh_layout = key
|
||||
|
||||
if self._get_mesh_cb is None:
|
||||
raise PipeliningMetadataError(
|
||||
f"Mesh not found in cache for mesh_dim_names={mesh_dim_names}, "
|
||||
f"mesh_layout={mesh_layout}, and no get_mesh callback provided. "
|
||||
f"Provide a get_mesh callback or use DTensors in static mode."
|
||||
)
|
||||
|
||||
mesh = self._get_mesh_cb(mesh_dim_names, mesh_layout)
|
||||
if mesh is None:
|
||||
raise PipeliningMetadataError(
|
||||
f"Mesh lookup failed: callback returned None for "
|
||||
f"mesh_dim_names={mesh_dim_names}, mesh_layout={mesh_layout}. "
|
||||
f"Ensure all stages use meshes from the same universe."
|
||||
)
|
||||
self._cache[key] = mesh
|
||||
return mesh
|
||||
|
||||
def put(self, key: MeshCacheKey, mesh: DeviceMesh) -> None:
|
||||
"""Add a mesh to the cache."""
|
||||
self._cache[key] = mesh
|
||||
|
||||
def update_from_tensors(self, tensors: tuple[torch.Tensor | None, ...]) -> None:
|
||||
"""Extract and cache meshes from any :class:`DTensor` instances in *tensors*."""
|
||||
for tensor in tensors:
|
||||
if isinstance(tensor, DTensor):
|
||||
mesh = tensor.device_mesh
|
||||
dim_names = tuple(mesh.mesh_dim_names) if mesh.mesh_dim_names else ()
|
||||
mesh_layout = mesh._layout
|
||||
key = (dim_names, mesh_layout)
|
||||
if key not in self._cache:
|
||||
self._cache[key] = mesh
|
||||
|
||||
def __contains__(self, key: MeshCacheKey) -> bool:
|
||||
return key in self._cache
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Inference mode enum
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InferenceMode(Enum):
|
||||
"""Pipeline-level metadata inference mode, determined collectively across all PP ranks.
|
||||
|
||||
The mode is set by the schedule (not individual stages) because
|
||||
``has_backward`` is only known at schedule creation time and all
|
||||
stages must agree to avoid P2P hangs.
|
||||
|
||||
.. attribute:: STATIC
|
||||
|
||||
All stages have sufficient metadata; runtime inference is skipped.
|
||||
|
||||
.. attribute:: DYNAMIC
|
||||
|
||||
At least one stage requires runtime metadata inference.
|
||||
"""
|
||||
|
||||
STATIC = "static"
|
||||
DYNAMIC = "dynamic"
|
||||
|
||||
@classmethod
|
||||
def needs_dynamic(cls, meta: _StageMeta, stage_has_backward: bool) -> bool:
|
||||
"""Determine whether dynamic metadata inference is needed for a stage.
|
||||
|
||||
Args:
|
||||
meta: Stage metadata from user-provided args.
|
||||
stage_has_backward: Whether a backward pass will be performed.
|
||||
|
||||
Returns:
|
||||
``True`` if dynamic inference is needed.
|
||||
"""
|
||||
# Case 1: Forward metadata incomplete → needs DYNAMIC
|
||||
if not meta.is_complete_for_forward():
|
||||
return True
|
||||
|
||||
# Case 2: No DTensors → STATIC is fine (bwd metadata derivable from fwd metadata)
|
||||
if not meta.has_dtensors():
|
||||
return False
|
||||
|
||||
# Case 3: No backward needed → STATIC is fine (don't need grad metadata)
|
||||
if not stage_has_backward:
|
||||
return False
|
||||
|
||||
# Case 4: DTensors with backward but missing ANY grad metadata → needs DYNAMIC
|
||||
# Both input_grads AND output_grads are required for static mode with DTensors
|
||||
if meta.input_grads is None or meta.output_grads is None:
|
||||
return True
|
||||
|
||||
# Case 5: DTensors with complete grads → STATIC is fine
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Utility functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def flatten_args(args, *, detach: bool = False):
|
||||
"""Flatten ``args`` into a list, optionally detaching tensors.
|
||||
|
||||
Args:
|
||||
args: Nested arguments to flatten.
|
||||
detach: If ``True``, detach tensors while preserving ``requires_grad``.
|
||||
|
||||
Returns:
|
||||
``(new_args, flat_detached_args)`` when ``detach=True``;
|
||||
``flat_args`` list otherwise.
|
||||
"""
|
||||
flat_args, treespec = tree_flatten(args)
|
||||
|
||||
if detach:
|
||||
flat_detached = [
|
||||
a.detach().requires_grad_(a.requires_grad)
|
||||
if isinstance(a, torch.Tensor)
|
||||
else a
|
||||
for a in flat_args
|
||||
]
|
||||
new_args = tree_unflatten(flat_detached, treespec)
|
||||
return new_args, flat_detached
|
||||
|
||||
return flat_args
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
def flatten_args_detach(args):
|
||||
"""Flatten and detach. Deprecated: use ``flatten_args(args, detach=True)``."""
|
||||
return flatten_args(args, detach=True)
|
||||
|
||||
|
||||
def generate_stage_to_rank_mapping(
|
||||
pp_size: int, num_stages: int, style: str = "loop"
|
||||
) -> dict[int, int]:
|
||||
"""
|
||||
Compute the stage id to rank mapping for either a looped or V-style schedule.
|
||||
|
||||
Most commonly num_stages == pp_size * 2, but this function can be used to
|
||||
compute the mapping for any number of stages per rank.
|
||||
"""
|
||||
mapping = {}
|
||||
if style == "loop":
|
||||
for stage_index in range(num_stages):
|
||||
mapping[stage_index] = stage_index % pp_size
|
||||
elif style == "v":
|
||||
if num_stages % pp_size != 0:
|
||||
raise ValueError(
|
||||
f"num_stages {num_stages} must be evenly divisible by pp_size {pp_size} for V schedules"
|
||||
)
|
||||
|
||||
rank_index = 0
|
||||
for stage_index in range(num_stages):
|
||||
mapping[stage_index] = rank_index
|
||||
# dont change rank if we are on the border (to keep v shape)
|
||||
if (stage_index + 1) % pp_size == 0:
|
||||
continue
|
||||
if (stage_index // pp_size) % 2 == 0:
|
||||
rank_index += 1
|
||||
else:
|
||||
rank_index -= 1
|
||||
else:
|
||||
raise ValueError(f"Style {style} is not supported.")
|
||||
return mapping
|
||||
|
||||
|
||||
def generate_rank_to_stage_mapping(
|
||||
pp_size: int, num_stages: int, style: str = "loop"
|
||||
) -> dict[int, list[int]]:
|
||||
"""
|
||||
Compute the rank to stage id mapping for either a looped or V-style schedule.
|
||||
|
||||
This function inverts the stage_to_rank_mapping to get which stages are assigned to each rank.
|
||||
|
||||
Returns a dictionary mapping rank -> list of stage indices assigned to that rank.
|
||||
"""
|
||||
stage_to_rank = generate_stage_to_rank_mapping(pp_size, num_stages, style)
|
||||
|
||||
# Invert the mapping: rank -> list of stages
|
||||
rank_to_stages: dict[int, list[int]] = {}
|
||||
for stage_id, rank in stage_to_rank.items():
|
||||
if rank not in rank_to_stages:
|
||||
rank_to_stages[rank] = []
|
||||
rank_to_stages[rank].append(stage_id)
|
||||
|
||||
# Sort the stage lists for each rank to ensure consistent ordering
|
||||
for stages in rank_to_stages.values():
|
||||
stages.sort()
|
||||
|
||||
return rank_to_stages
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PipeInfo:
|
||||
"""
|
||||
Captures information for a pipeline (`Pipe` object).
|
||||
"""
|
||||
|
||||
graph: fx.Graph
|
||||
num_stages: int
|
||||
has_loss_and_backward: bool
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Metadata extraction helpers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def extract_tensor_meta(tensor: torch.Tensor) -> TensorMeta:
|
||||
"""Extract metadata from a tensor.
|
||||
|
||||
Handles both plain Tensor and DTensor correctly: DTensors are
|
||||
dispatched to ``_DTensorMeta.from_dtensor`` which captures local
|
||||
shard attributes plus global shape/placement info, while plain
|
||||
tensors use ``_TensorMeta.from_tensor``.
|
||||
|
||||
Args:
|
||||
tensor: A plain tensor or DTensor.
|
||||
|
||||
Returns:
|
||||
``_TensorMeta`` for plain tensors, ``_DTensorMeta`` for DTensors.
|
||||
"""
|
||||
if isinstance(tensor, DTensor):
|
||||
return _DTensorMeta.from_dtensor(tensor)
|
||||
else:
|
||||
return _TensorMeta.from_tensor(tensor)
|
||||
|
||||
|
||||
@overload
|
||||
def extract_tensor_metas(
|
||||
tensors: tuple[torch.Tensor, ...] | None,
|
||||
*,
|
||||
allow_none: Literal[False] = ...,
|
||||
) -> tuple[TensorMeta, ...] | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def extract_tensor_metas(
|
||||
tensors: tuple[torch.Tensor | None, ...] | None,
|
||||
*,
|
||||
allow_none: Literal[True],
|
||||
) -> tuple[TensorMeta | None, ...] | None: ...
|
||||
|
||||
|
||||
def extract_tensor_metas(
|
||||
tensors: tuple[torch.Tensor | None, ...] | tuple[torch.Tensor, ...] | None,
|
||||
*,
|
||||
allow_none: bool = False,
|
||||
) -> tuple[TensorMeta | None, ...] | None:
|
||||
"""Extract metadata from a tuple of tensors.
|
||||
|
||||
Args:
|
||||
tensors: Tuple of tensors (may include ``None`` when ``allow_none=True``).
|
||||
allow_none: If ``True``, preserve ``None`` elements (for gradients).
|
||||
|
||||
Returns:
|
||||
Tuple of ``TensorMeta``, or ``None`` if ``tensors`` is ``None``.
|
||||
|
||||
Raises:
|
||||
PipeliningMetadataError: If ``None`` found and ``allow_none=False``.
|
||||
"""
|
||||
if tensors is None:
|
||||
return None
|
||||
|
||||
metas_with_none: list[TensorMeta | None] = []
|
||||
has_none = False
|
||||
for t in tensors:
|
||||
if isinstance(t, torch.Tensor):
|
||||
metas_with_none.append(extract_tensor_meta(t))
|
||||
else:
|
||||
has_none = True
|
||||
metas_with_none.append(None)
|
||||
if not allow_none and has_none:
|
||||
raise PipeliningMetadataError(
|
||||
"None values are not allowed in tensor metadata tuples. "
|
||||
"Use allow_none=True for optional values."
|
||||
)
|
||||
return tuple(metas_with_none)
|
||||
|
||||
|
||||
def to_local_if_dtensor(tensor: torch.Tensor, detach: bool = False) -> torch.Tensor:
|
||||
"""Convert a DTensor to its local shard, or return a plain tensor as-is.
|
||||
|
||||
When ``detach=True``, the tensor is detached before conversion —
|
||||
this applies to both DTensors and plain tensors.
|
||||
|
||||
Args:
|
||||
tensor: A tensor that may be a DTensor.
|
||||
detach: If ``True``, detach before ``to_local()`` to avoid
|
||||
redistribution during backward.
|
||||
|
||||
Returns:
|
||||
The local tensor component.
|
||||
"""
|
||||
maybe_detached_tensor = tensor.detach() if detach else tensor
|
||||
if isinstance(maybe_detached_tensor, DTensor):
|
||||
return maybe_detached_tensor.to_local()
|
||||
return maybe_detached_tensor
|
||||
|
||||
|
||||
@overload
|
||||
def validate_and_normalize_to_tuple(
|
||||
args: torch.Tensor | tuple[torch.Tensor, ...] | list[torch.Tensor] | None,
|
||||
allow_none: Literal[False] = ...,
|
||||
) -> tuple[torch.Tensor, ...] | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def validate_and_normalize_to_tuple(
|
||||
args: torch.Tensor
|
||||
| tuple[torch.Tensor | None, ...]
|
||||
| list[torch.Tensor | None]
|
||||
| None,
|
||||
allow_none: Literal[True] = ...,
|
||||
) -> tuple[torch.Tensor | None, ...] | None: ...
|
||||
|
||||
|
||||
def validate_and_normalize_to_tuple(
|
||||
args: torch.Tensor
|
||||
| tuple[torch.Tensor, ...]
|
||||
| tuple[torch.Tensor | None, ...]
|
||||
| list[torch.Tensor]
|
||||
| list[torch.Tensor | None]
|
||||
| None,
|
||||
allow_none: bool = False,
|
||||
) -> tuple[torch.Tensor | None, ...] | tuple[torch.Tensor, ...] | None:
|
||||
"""Normalize ``args`` to a tuple and validate that all elements are tensors.
|
||||
|
||||
Args:
|
||||
args: A single tensor, tuple/list of tensors, or ``None``.
|
||||
allow_none: If ``True``, permit ``None`` elements (for gradients).
|
||||
|
||||
Returns:
|
||||
Tuple of tensors, or ``None`` if ``args`` is ``None``.
|
||||
|
||||
Raises:
|
||||
PipeliningMetadataError: On non-tensor values
|
||||
(or ``None`` when ``allow_none=False``).
|
||||
"""
|
||||
if args is None:
|
||||
return None
|
||||
elif isinstance(args, torch.Tensor):
|
||||
return (args,)
|
||||
elif isinstance(args, (tuple, list)):
|
||||
for i, arg in enumerate(args):
|
||||
if arg is None:
|
||||
if not allow_none:
|
||||
raise PipeliningMetadataError(
|
||||
f"Stage arg[{i}] is None. "
|
||||
f"Stage args must be tensors. Use kwargs for optional values."
|
||||
)
|
||||
continue
|
||||
if not isinstance(arg, torch.Tensor):
|
||||
raise PipeliningMetadataError(
|
||||
f"Stage arg[{i}] has type {type(arg).__name__}. "
|
||||
f"All stage args must be tensors. Use kwargs for non-tensor inputs."
|
||||
)
|
||||
# Normalize list to tuple
|
||||
return tuple(args) if isinstance(args, list) else args
|
||||
else:
|
||||
raise PipeliningMetadataError(
|
||||
f"Stage args must be a tensor, tuple, or list of tensors, got {type(args).__name__}."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Validation functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def validate_metadata(
|
||||
desc: str,
|
||||
expected: TensorMeta,
|
||||
actual: torch.Tensor | TensorMeta,
|
||||
*,
|
||||
raise_on_mismatch: bool = False,
|
||||
warn_on_mismatch: bool = False,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Compare expected metadata against actual tensor or metadata.
|
||||
|
||||
This is the unified validation/comparison function that uses get_diff() from
|
||||
metadata classes. Works with both plain tensors and DTensors.
|
||||
|
||||
For plain tensors: compares shape/stride/dtype/requires_grad.
|
||||
For DTensors: compares all properties including global shape and placements.
|
||||
|
||||
Args:
|
||||
desc: Description for error/warning messages.
|
||||
expected: Expected tensor metadata (_TensorMeta or _DTensorMeta).
|
||||
actual: Actual tensor or metadata to compare against.
|
||||
raise_on_mismatch: If True, raise PipeliningMetadataError on mismatch.
|
||||
warn_on_mismatch: If True, issue a warning on mismatch.
|
||||
|
||||
Returns:
|
||||
List of differences (empty if metadata matches).
|
||||
|
||||
Raises:
|
||||
PipeliningMetadataError: If raise_on_mismatch=True and differences exist.
|
||||
"""
|
||||
# Extract metadata if actual is a tensor
|
||||
if isinstance(actual, torch.Tensor):
|
||||
actual_meta = extract_tensor_meta(actual)
|
||||
else:
|
||||
actual_meta = actual
|
||||
|
||||
# Type check: ensure both are same type for meaningful comparison
|
||||
if type(expected) is not type(actual_meta):
|
||||
type_diff = [
|
||||
f"type: expected {type(expected).__name__}, got {type(actual_meta).__name__}"
|
||||
]
|
||||
if raise_on_mismatch:
|
||||
raise PipeliningMetadataError(f"{desc}: {type_diff[0]}")
|
||||
if warn_on_mismatch:
|
||||
warnings.warn(
|
||||
f"{desc}: Metadata type mismatch. {type_diff[0]}. "
|
||||
f"Using dynamically inferred metadata instead.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return type_diff
|
||||
|
||||
# Use get_diff() from the metadata class
|
||||
diffs = expected.get_diff(actual_meta)
|
||||
|
||||
if diffs:
|
||||
if raise_on_mismatch:
|
||||
raise PipeliningMetadataError(f"{desc}: {'; '.join(diffs)}")
|
||||
if warn_on_mismatch:
|
||||
warnings.warn(
|
||||
f"{desc}: Metadata mismatch. {'; '.join(diffs)}. "
|
||||
f"Using dynamically inferred metadata instead.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return diffs
|
||||
|
||||
|
||||
def validate_tensors_metadata(
|
||||
desc: str,
|
||||
expected: tuple[TensorMeta | None, ...],
|
||||
actual: tuple[torch.Tensor | TensorMeta | None, ...],
|
||||
*,
|
||||
raise_on_mismatch: bool = True,
|
||||
warn_on_mismatch: bool = False,
|
||||
) -> list[str]:
|
||||
"""Validate metadata for a tuple of tensors element-wise.
|
||||
|
||||
Args:
|
||||
desc: Description prefix for error/warning messages.
|
||||
expected: Tuple of expected metadata (may include ``None`` for grads).
|
||||
actual: Tuple of actual tensors or metadata to compare against.
|
||||
raise_on_mismatch: If ``True``, raise on the first mismatch.
|
||||
warn_on_mismatch: If ``True``, issue warnings for mismatches.
|
||||
|
||||
Returns:
|
||||
Aggregated list of difference strings.
|
||||
|
||||
Raises:
|
||||
PipeliningMetadataError: If lengths differ or on mismatch.
|
||||
"""
|
||||
if len(expected) != len(actual):
|
||||
msg = f"{desc}: expected {len(expected)} tensors, got {len(actual)}"
|
||||
if raise_on_mismatch:
|
||||
raise PipeliningMetadataError(msg)
|
||||
if warn_on_mismatch:
|
||||
warnings.warn(msg, UserWarning, stacklevel=2)
|
||||
return [msg]
|
||||
|
||||
all_diffs: list[str] = []
|
||||
for i, (exp, act) in enumerate(zip(expected, actual, strict=True)):
|
||||
if exp is None and act is None:
|
||||
continue
|
||||
if exp is None or act is None:
|
||||
msg = (
|
||||
f"{desc}[{i}]: expected {'None' if exp is None else 'metadata'}, "
|
||||
f"got {'None' if act is None else 'metadata'}"
|
||||
)
|
||||
if raise_on_mismatch:
|
||||
raise PipeliningMetadataError(msg)
|
||||
if warn_on_mismatch:
|
||||
warnings.warn(msg, UserWarning, stacklevel=2)
|
||||
all_diffs.append(msg)
|
||||
continue
|
||||
diffs = validate_metadata(
|
||||
f"{desc}[{i}]",
|
||||
exp,
|
||||
act,
|
||||
raise_on_mismatch=raise_on_mismatch,
|
||||
warn_on_mismatch=warn_on_mismatch,
|
||||
)
|
||||
all_diffs.extend(diffs)
|
||||
return all_diffs
|
||||
|
||||
|
||||
def validate_static_arg_grad_correspondence(
|
||||
stage_index: int,
|
||||
args: tuple[torch.Tensor, ...],
|
||||
grads: tuple[torch.Tensor | None, ...],
|
||||
is_input: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Validate the args↔grads contract for static mode.
|
||||
|
||||
Enforces four rules for each (arg, grad) pair:
|
||||
1. len(args) must equal len(grads).
|
||||
2. If arg.requires_grad is False, grad must be None.
|
||||
3. If arg.requires_grad is True and grad is None, emit a warning
|
||||
(this is legal at pipeline boundaries but may indicate a bug).
|
||||
4. If arg is a DTensor with requires_grad=True and grad is not None,
|
||||
grad must also be a DTensor.
|
||||
|
||||
Args:
|
||||
stage_index: The stage index for error messages.
|
||||
args: Tuple of forward tensors.
|
||||
grads: Tuple of gradient tensors (can include None).
|
||||
is_input: True for input_args/input_grads, False for output_args/output_grads.
|
||||
|
||||
Raises:
|
||||
PipeliningMetadataError: If any hard rule (1, 2, or 4) is violated.
|
||||
"""
|
||||
kind = "input" if is_input else "output"
|
||||
args_name = f"{kind}_args"
|
||||
grads_name = f"{kind}_grads"
|
||||
|
||||
# Rule 1: lengths must match
|
||||
if len(args) != len(grads):
|
||||
raise PipeliningMetadataError(
|
||||
f"Stage {stage_index}: {grads_name} length ({len(grads)}) does not match "
|
||||
f"{args_name} length ({len(args)}). Each forward tensor must have a "
|
||||
f"corresponding gradient entry (use None for tensors that don't require grad)."
|
||||
)
|
||||
|
||||
for i, (arg, grad) in enumerate(zip(args, grads, strict=True)):
|
||||
# Rule 2: no grad for a non-differentiable arg
|
||||
if not arg.requires_grad and grad is not None:
|
||||
raise PipeliningMetadataError(
|
||||
f"Stage {stage_index}: {args_name}[{i}] has requires_grad=False, "
|
||||
f"but {grads_name}[{i}] is not None ({type(grad).__name__}). "
|
||||
f"Non-differentiable tensors must have None as their gradient entry."
|
||||
)
|
||||
|
||||
# Rule 3: missing grad for a differentiable arg (warn, don't raise)
|
||||
if arg.requires_grad and grad is None:
|
||||
warnings.warn(
|
||||
f"Stage {stage_index}: {args_name}[{i}] has requires_grad=True, "
|
||||
f"but {grads_name}[{i}] is None. This is legal at pipeline boundaries "
|
||||
f"but may indicate a missing gradient.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Rule 4: DTensor arg must have DTensor grad
|
||||
if (
|
||||
isinstance(arg, DTensor)
|
||||
and arg.requires_grad
|
||||
and grad is not None
|
||||
and not isinstance(grad, DTensor)
|
||||
):
|
||||
raise PipeliningMetadataError(
|
||||
f"Stage {stage_index}: {args_name}[{i}] is a DTensor with requires_grad=True, "
|
||||
f"but {grads_name}[{i}] is {type(grad).__name__}, expected DTensor or None. "
|
||||
f"DTensor gradients may have different placements than forward tensors."
|
||||
)
|
||||
@@ -0,0 +1,621 @@
|
||||
# mypy: allow-untyped-defs
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
import logging
|
||||
import operator
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch.distributed.tensor import DTensor
|
||||
from torch.distributed.tensor.experimental import local_map
|
||||
from torch.fx.node import map_aggregate
|
||||
from torch.nn.attention.flex_attention import BlockMask
|
||||
from torch.utils._pytree import tree_flatten, tree_map, tree_unflatten
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TensorChunkSpec",
|
||||
"split_args_kwargs_into_chunks",
|
||||
"merge_chunks",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
_debug_mask_minibatches specifies to send masked versions of the mini-batch
|
||||
through instead of micro-batch slices--this can be used for more stable
|
||||
numerical testing (see [A Note About Correctness Testing])
|
||||
"""
|
||||
_debug_mask_minibatches = False
|
||||
|
||||
|
||||
class _CustomReducer:
|
||||
"""
|
||||
Custom reducer class that can be used to specify a custom operation that
|
||||
reduces losses of multiple microbatches into one value.
|
||||
|
||||
Example:
|
||||
>>> # xdoctest: +SKIP
|
||||
>>> sum_reducer = _CustomReducer(
|
||||
>>> torch.tensor(0.0),
|
||||
>>> lambda a, b: a + b
|
||||
>>> )
|
||||
"""
|
||||
|
||||
def __init__(self, init_value, reduce_fn):
|
||||
self.init_value = init_value
|
||||
self.reduce_fn = reduce_fn
|
||||
|
||||
|
||||
class _LossReducer(_CustomReducer):
|
||||
pass
|
||||
|
||||
|
||||
sum_reducer = _LossReducer(torch.tensor(0.0), operator.add)
|
||||
|
||||
# Default chunking dimension is 0. This is used for the case where the user did
|
||||
# not specify a chunking dimension.
|
||||
DEFAULT_CHUNK_DIM = 0
|
||||
|
||||
|
||||
class TensorChunkSpec:
|
||||
"""
|
||||
Class used to specify chunking of inputs
|
||||
"""
|
||||
|
||||
def __init__(self, split_dim):
|
||||
self.split_dim = split_dim
|
||||
|
||||
split_dim: int
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"{self.__class__.__module__}.{self.__class__.__name__}({self.split_dim})"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"TensorChunkSpec({self.split_dim})"
|
||||
|
||||
@staticmethod
|
||||
def from_tuple(
|
||||
chunk_dims: tuple[int, ...],
|
||||
):
|
||||
"""
|
||||
A helper for creating a tuple of `TensorChunkSpec` from a tuple of chunk
|
||||
dimensions (int's).
|
||||
Example:
|
||||
>>> # xdoctest: +SKIP
|
||||
>>> # There are three positional arguments to the model, and
|
||||
>>> # we are chunking them along dimension 0, 0 and 1, respectively
|
||||
>>> args_chunk_spec = TensorChunkSpec.from_tuple((0, 0, 1))
|
||||
"""
|
||||
args_chunk_spec = map_aggregate(
|
||||
chunk_dims,
|
||||
lambda dim: TensorChunkSpec(dim), # type: ignore[arg-type,return-value]
|
||||
)
|
||||
return args_chunk_spec
|
||||
|
||||
@staticmethod
|
||||
def from_dict(
|
||||
chunk_dims: dict[str, int],
|
||||
):
|
||||
"""
|
||||
A helper for creating a dictionary of `TensorChunkSpec` from a
|
||||
dictionary of chunk dimensions (int's).
|
||||
Example:
|
||||
>>> # xdoctest: +SKIP
|
||||
>>> # Chunk dimension 0 for the "id" argument, 1 for the "mask" argument
|
||||
>>> kwargs_chunk_spec = TensorChunkSpec.from_dict({"id": 0, "mask": 1})
|
||||
"""
|
||||
kwargs_chunk_spec = map_aggregate(
|
||||
chunk_dims,
|
||||
lambda dim: TensorChunkSpec(dim), # type: ignore[arg-type,return-value]
|
||||
)
|
||||
return kwargs_chunk_spec
|
||||
|
||||
|
||||
# Class used to specify replication of inputs
|
||||
class _Replicate:
|
||||
pass
|
||||
|
||||
|
||||
def _split_block_mask(
|
||||
block_mask: BlockMask,
|
||||
num_chunks: int,
|
||||
) -> list[BlockMask]:
|
||||
"""Given a block mask, split the block mask along the batch dimension (dim0).
|
||||
|
||||
Args:
|
||||
block_mask: Block mask to split
|
||||
num_chunks: Number of chunks to split the block mask into
|
||||
|
||||
Returns:
|
||||
chunk_block_masks: List of chunked block masks
|
||||
"""
|
||||
|
||||
# BlockMask will broadcast if B is 1.
|
||||
if block_mask.kv_num_blocks.size(0) == 1:
|
||||
return [block_mask] * num_chunks
|
||||
|
||||
if not block_mask.kv_num_blocks.size(0) >= num_chunks:
|
||||
raise AssertionError(
|
||||
"Block mask has fewer batch size than the number of chunks. "
|
||||
)
|
||||
|
||||
batch_dim = 0
|
||||
kv_num_blocks_chunks = torch.tensor_split(
|
||||
block_mask.kv_num_blocks, num_chunks, batch_dim
|
||||
)
|
||||
kv_indices_chunks = torch.tensor_split(block_mask.kv_indices, num_chunks, batch_dim)
|
||||
full_kv_num_blocks_chunks = (
|
||||
torch.tensor_split(block_mask.full_kv_num_blocks, num_chunks, batch_dim)
|
||||
if block_mask.full_kv_num_blocks is not None
|
||||
else [None] * num_chunks
|
||||
)
|
||||
full_kv_indices_chunks = (
|
||||
torch.tensor_split(block_mask.full_kv_indices, num_chunks, batch_dim)
|
||||
if block_mask.full_kv_indices is not None
|
||||
else [None] * num_chunks
|
||||
)
|
||||
|
||||
chunk_block_masks = []
|
||||
batch_offset = 0
|
||||
for chunk_idx in range(num_chunks):
|
||||
|
||||
def create_mask_mod(idx):
|
||||
def batch_offset_mask_mod(b, h, q_idx, kv_idx):
|
||||
b_offset = torch.full_like(b, idx)
|
||||
return block_mask.mask_mod(b + b_offset, h, q_idx, kv_idx)
|
||||
|
||||
return batch_offset_mask_mod
|
||||
|
||||
chunk_block_masks.append(
|
||||
BlockMask.from_kv_blocks(
|
||||
kv_num_blocks=kv_num_blocks_chunks[chunk_idx],
|
||||
kv_indices=kv_indices_chunks[chunk_idx],
|
||||
full_kv_num_blocks=full_kv_num_blocks_chunks[chunk_idx],
|
||||
full_kv_indices=full_kv_indices_chunks[chunk_idx],
|
||||
BLOCK_SIZE=block_mask.BLOCK_SIZE,
|
||||
mask_mod=create_mask_mod(batch_offset),
|
||||
seq_lengths=block_mask.seq_lengths,
|
||||
)
|
||||
)
|
||||
batch_offset += kv_num_blocks_chunks[chunk_idx].size(0)
|
||||
return chunk_block_masks
|
||||
|
||||
|
||||
def _split_tensor(
|
||||
tensor: torch.Tensor,
|
||||
spec: TensorChunkSpec,
|
||||
num_chunks: int,
|
||||
) -> Sequence[torch.Tensor]:
|
||||
"""Given a tensor, and a chunking spec, split the tensor.
|
||||
Args:
|
||||
|
||||
tensor: Tensor to split
|
||||
spec: Chunking spec
|
||||
num_chunks: Number of chunks to split the tensor into
|
||||
|
||||
Returns:
|
||||
chunk_tensors: List of chunked tensors
|
||||
"""
|
||||
|
||||
if not tensor.size(spec.split_dim) >= num_chunks:
|
||||
raise AssertionError(
|
||||
f"Tensor size {tensor.size(spec.split_dim)} is smaller than num_chunks"
|
||||
)
|
||||
|
||||
_is_dtensor = isinstance(tensor, DTensor)
|
||||
|
||||
if _is_dtensor:
|
||||
# Use local_map to split locally and preserve placements.
|
||||
# Going through DTensor dispatch would convert Shard(split_dim) to
|
||||
# Replicate() via an implicit all-gather, which is both wasteful and
|
||||
# semantically wrong for PP microbatch splitting.
|
||||
placements = tensor.placements
|
||||
split_fn = local_map(
|
||||
lambda t: torch.tensor_split(t, num_chunks, spec.split_dim),
|
||||
out_placements=(placements,) * num_chunks,
|
||||
in_placements=(placements,),
|
||||
)
|
||||
chunk_tensors: Sequence[torch.Tensor] = split_fn(tensor) # type: ignore[assignment]
|
||||
else:
|
||||
chunk_tensors = torch.tensor_split(tensor, num_chunks, spec.split_dim)
|
||||
|
||||
# tensor_split on a leaf tensor produces non-leaf views that won't
|
||||
# accumulate .grad during torch.autograd.backward(). Call retain_grad()
|
||||
# on those views so that stage_backward() can read .grad from them.
|
||||
if tensor.requires_grad and tensor.is_leaf:
|
||||
for chunk in chunk_tensors:
|
||||
chunk.retain_grad()
|
||||
|
||||
if not _debug_mask_minibatches:
|
||||
return chunk_tensors
|
||||
|
||||
def _expand_chunks(
|
||||
orig: torch.Tensor, *chunks: torch.Tensor
|
||||
) -> tuple[torch.Tensor, ...]:
|
||||
expanded = []
|
||||
idx = 0
|
||||
for chunk in chunks:
|
||||
new_val = torch.zeros_like(orig)
|
||||
upper = idx + chunk.size(spec.split_dim)
|
||||
slices: list[slice] = [slice(None)] * new_val.ndim
|
||||
slices[spec.split_dim] = slice(idx, upper)
|
||||
new_val[slices] = chunk
|
||||
expanded.append(new_val)
|
||||
idx += chunk.size(spec.split_dim)
|
||||
return tuple(expanded)
|
||||
|
||||
if _is_dtensor:
|
||||
placements = tensor.placements
|
||||
n = len(chunk_tensors)
|
||||
expand_fn = local_map(
|
||||
_expand_chunks,
|
||||
out_placements=(placements,) * n,
|
||||
in_placements=(placements,) + (placements,) * n,
|
||||
)
|
||||
return list(expand_fn(tensor, *chunk_tensors)) # type: ignore[arg-type]
|
||||
else:
|
||||
return list(_expand_chunks(tensor, *chunk_tensors))
|
||||
|
||||
|
||||
def _shard_dict_of_args(
|
||||
args_dict,
|
||||
args_chunk_spec,
|
||||
num_chunks,
|
||||
):
|
||||
"""
|
||||
Given a dictionary of args, and a dictionary of chunking specs, shard the
|
||||
args according to the chunking specs.
|
||||
|
||||
Args:
|
||||
args_dict: Dictionary of args
|
||||
args_chunk_spec: Dictionary of chunking specs
|
||||
num_chunks: Number of chunks to shard the args into
|
||||
|
||||
Returns:
|
||||
args_split: List of sharded args
|
||||
"""
|
||||
|
||||
if not args_dict:
|
||||
return [{} for _ in range(num_chunks)]
|
||||
|
||||
if not len(args_dict) == len(args_chunk_spec):
|
||||
raise AssertionError(
|
||||
f"args_dict.keys() = {list(args_dict.keys())} "
|
||||
f"args_chunk_spec.keys() = {list(args_chunk_spec.keys())}"
|
||||
)
|
||||
if args_chunk_spec is None:
|
||||
raise AssertionError("args_chunk_spec should have been set by caller")
|
||||
|
||||
values, tree_spec = tree_flatten(
|
||||
args_dict, is_leaf=lambda x: isinstance(x, BlockMask)
|
||||
)
|
||||
chunk_specs, _ = tree_flatten(
|
||||
args_chunk_spec, is_leaf=lambda x: isinstance(x, BlockMask)
|
||||
)
|
||||
|
||||
# First check and find the actual number of chunks
|
||||
split_sizes = []
|
||||
for v, spec in zip(values, chunk_specs, strict=True):
|
||||
# The original logic is "spec is _Replicate". This doesn't seem to be
|
||||
# correct. But we keep it for backward compatibility.
|
||||
if spec is _Replicate or isinstance(spec, _Replicate):
|
||||
split_sizes.append(num_chunks)
|
||||
elif isinstance(v, torch.Tensor):
|
||||
if not isinstance(spec, TensorChunkSpec):
|
||||
raise AssertionError(f"Expected TensorChunkSpec, got {type(spec)}")
|
||||
split_sizes.append(v.size(spec.split_dim))
|
||||
elif isinstance(v, BlockMask):
|
||||
if not isinstance(spec, TensorChunkSpec):
|
||||
raise AssertionError(f"Expected TensorChunkSpec, got {type(spec)}")
|
||||
if not spec.split_dim == 0:
|
||||
raise AssertionError("BlockMask only supports split_dim=0")
|
||||
# BlockMask will broadcast if B is 1.
|
||||
if v.kv_num_blocks.size(0) == 1:
|
||||
split_sizes.append(num_chunks)
|
||||
else:
|
||||
split_sizes.append(v.kv_num_blocks.size(0))
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported chunk spec: {spec} and value: {v} combination."
|
||||
)
|
||||
result_num_chunks = min(*split_sizes, num_chunks)
|
||||
|
||||
flat_split_results: list[Any] = [[] for _ in range(result_num_chunks)]
|
||||
for v, spec in zip(values, chunk_specs, strict=True):
|
||||
v_splits: Sequence[Any] = []
|
||||
if spec is _Replicate or isinstance(spec, _Replicate):
|
||||
v_splits = [v] * result_num_chunks
|
||||
elif isinstance(v, torch.Tensor):
|
||||
v_splits = _split_tensor(v, spec, result_num_chunks)
|
||||
elif isinstance(v, BlockMask):
|
||||
v_splits = _split_block_mask(v, result_num_chunks)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported chunk spec: {spec} and value: {v} combination."
|
||||
)
|
||||
|
||||
for _flat_split_result, _v_split in zip(
|
||||
flat_split_results, v_splits, strict=True
|
||||
):
|
||||
_flat_split_result.append(_v_split)
|
||||
|
||||
return [
|
||||
tree_unflatten(_flat_split_result, tree_spec)
|
||||
for _flat_split_result in flat_split_results
|
||||
]
|
||||
|
||||
|
||||
def split_args_kwargs_into_chunks(
|
||||
args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any] | None,
|
||||
chunks: int,
|
||||
args_chunk_spec: tuple[TensorChunkSpec, ...] | None = None,
|
||||
kwargs_chunk_spec: dict[str, TensorChunkSpec] | None = None,
|
||||
) -> tuple[list[tuple], list[dict]]:
|
||||
"""
|
||||
Given a sequence of args and kwargs, split them into a number of chunks
|
||||
according to their respective chunking specs.
|
||||
|
||||
Args:
|
||||
args: Tuple of args
|
||||
kwargs: Dict of kwargs
|
||||
chunks: Number of chunks to split the args and kwargs into
|
||||
args_chunk_spec: chunking specs for args, in same shape as args
|
||||
kwargs_chunk_spec: chunking specs for kwargs, in same shape as kwargs
|
||||
|
||||
Returns:
|
||||
args_split: List of sharded args
|
||||
kwargs_split: List of sharded kwargs
|
||||
"""
|
||||
# Given `args` and `kwargs`, we want to yield a set of `chunks` args and kwargs such that
|
||||
# the constituent Tensor values have been sharded/replicated according to the `args_chunk_spec`
|
||||
# and `kwargs_chunk_spec` specifications. The steps are as follows:
|
||||
#
|
||||
# 1. Use pytree.tree_flatten to flatten each arg and its spec into nto a 1d array of values.
|
||||
# To use a running example: suppose our inputs look like
|
||||
#
|
||||
# args = ([A, [B, C]], D) args_spec = ([None, [None, TensorChunkSpec]], None)
|
||||
# (kwargs not shown but it's a similar process)
|
||||
#
|
||||
# Then for this step we would end up with
|
||||
#
|
||||
# args = ([A, B, C], D) args_spec = ([None, None, TensorChunkSpec], None)
|
||||
#
|
||||
# 2. Shard or replicate the arguments subject to the policy in the spec. Suppose chunks = 2
|
||||
#
|
||||
# args = ([[A, A], [B, B], [C_1, C_2]], [D, D])
|
||||
#
|
||||
# 3. Rotate the nesting order such that chunks are the outer dimension
|
||||
#
|
||||
# args_chunks = [
|
||||
# ([A, B, C_1], D),
|
||||
# ([A, B, C_2], D),
|
||||
# ]
|
||||
#
|
||||
# 4. Unflatten each chunk according to the spec
|
||||
#
|
||||
# args_chunks = [
|
||||
# ([A, [B, C_1]], D),
|
||||
# ([A, [B, C_2]], D),
|
||||
# ]
|
||||
|
||||
# TODO: _debug_mask_minibatches
|
||||
# Handle the case where kwargs is None
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
|
||||
# If user did not provide args_chunk_spec or kwargs_chunk_spec, we extend
|
||||
# their format and use default chunking along dim 0
|
||||
def default_spec(v):
|
||||
if isinstance(v, torch.Tensor | BlockMask):
|
||||
return TensorChunkSpec(DEFAULT_CHUNK_DIM)
|
||||
else:
|
||||
return _Replicate()
|
||||
|
||||
if args_chunk_spec is None:
|
||||
args_chunk_spec = tree_map(
|
||||
default_spec, args, is_leaf=lambda v: isinstance(v, BlockMask)
|
||||
)
|
||||
|
||||
if kwargs_chunk_spec is None:
|
||||
kwargs_chunk_spec = tree_map(
|
||||
default_spec, kwargs, is_leaf=lambda v: isinstance(v, BlockMask)
|
||||
)
|
||||
|
||||
args_split_dict = _shard_dict_of_args(
|
||||
dict(enumerate(args)),
|
||||
dict(enumerate(args_chunk_spec)),
|
||||
chunks,
|
||||
)
|
||||
real_num_chunks = len(args_split_dict)
|
||||
|
||||
kwargs_split = _shard_dict_of_args(
|
||||
kwargs,
|
||||
kwargs_chunk_spec,
|
||||
real_num_chunks,
|
||||
)
|
||||
|
||||
if len(kwargs_split) < real_num_chunks:
|
||||
# In case kwargs are sharded into less chunks
|
||||
# e.g. when `args` has no tensor, just values
|
||||
real_num_chunks = len(kwargs_split)
|
||||
# Re-shard args
|
||||
args_split_dict = _shard_dict_of_args(
|
||||
dict(enumerate(args)),
|
||||
dict(enumerate(args_chunk_spec)),
|
||||
real_num_chunks,
|
||||
)
|
||||
|
||||
if len(args_split_dict) != len(kwargs_split):
|
||||
raise RuntimeError(
|
||||
"args and kwargs are split into different number of chunks: "
|
||||
f"{len(args_split_dict)}, {len(kwargs_split)}"
|
||||
)
|
||||
|
||||
args_split = [
|
||||
tuple(chunk_args[i] for i in range(len(chunk_args)))
|
||||
for chunk_args in args_split_dict
|
||||
]
|
||||
|
||||
return args_split, kwargs_split
|
||||
|
||||
|
||||
def merge_chunks(
|
||||
chunks: list[Any],
|
||||
chunk_spec,
|
||||
):
|
||||
"""
|
||||
Given a list of chunks, merge them into a single value according to
|
||||
the chunk spec.
|
||||
|
||||
Args:
|
||||
chunks: list of chunks
|
||||
chunk_spec: Chunking spec for the chunks
|
||||
|
||||
Returns:
|
||||
value: Merged value
|
||||
"""
|
||||
# This is essentially the inverse of `split_args_kwargs_into_chunks`, so the
|
||||
# steps are similar to the steps in that function but in reverse. Given the
|
||||
# input values:
|
||||
#
|
||||
# chunks = [
|
||||
# ([A, [B, C_1]], D),
|
||||
# ([A, [B, C_2]], D),
|
||||
# ]
|
||||
# args_spec = ([None, [None, TensorChunkSpec]], None)
|
||||
#
|
||||
# 1. Flatten the chunks according to the chunk_spec
|
||||
#
|
||||
# chunks_flat = [
|
||||
# ([A, B, C_1], D),
|
||||
# ([A, B, C_2], D),
|
||||
# ]
|
||||
#
|
||||
# 2. Rotate the nesting order such that chunks are the inner dimension
|
||||
#
|
||||
# value_inner = ([A, B, [C_1, C_2]], D)
|
||||
#
|
||||
# 3. Concatenate sharded arguments
|
||||
#
|
||||
# value_combined = ([A, B, C], D)
|
||||
#
|
||||
# 4. Unflatten the combined args given the spec
|
||||
#
|
||||
# value = ([A, [B, C]], D)
|
||||
|
||||
# Preliminary: flatten the chunk spec
|
||||
if chunk_spec is not None:
|
||||
spec_flattened, flatten_spec = tree_flatten(chunk_spec)
|
||||
else:
|
||||
# If chunk_spec is not provided, we will merge chunks along the default dimension (0), for all output fields
|
||||
# We obtain the output structure by flattening chunk 0 and generate the chunk_spec
|
||||
chunk0_flat, flatten_spec = tree_flatten(chunks[0])
|
||||
spec_flattened = [TensorChunkSpec(DEFAULT_CHUNK_DIM)] * len(chunk0_flat)
|
||||
|
||||
# Stage 1: flatten chunks
|
||||
# chunks_flattened : [num chunks, num args]
|
||||
chunks_flattened = []
|
||||
|
||||
for chunk in chunks:
|
||||
chunk_flattened, _ = tree_flatten(chunk)
|
||||
if len(chunk_flattened) != len(spec_flattened):
|
||||
raise ValueError(f"Chunk {chunk} did not match chunk spec {chunk_spec}")
|
||||
|
||||
chunks_flattened.append(chunk_flattened)
|
||||
|
||||
# Stage 2 and 3: Rotate nesting order s.t. chunks are inner dimension and
|
||||
# concatenate sharded operands
|
||||
# args_flattened : [num args]
|
||||
args_flattened = []
|
||||
for arg_idx, arg in enumerate(spec_flattened):
|
||||
if isinstance(arg, TensorChunkSpec):
|
||||
partial_values = [
|
||||
chunks_flattened[chunk_idx][arg_idx]
|
||||
for chunk_idx in range(len(chunks_flattened))
|
||||
]
|
||||
|
||||
if _debug_mask_minibatches:
|
||||
# Infer size of individual chunks by running `tensor_split` again
|
||||
overall_shape = partial_values[0].shape
|
||||
for val in partial_values[1:]:
|
||||
if not val.shape == overall_shape:
|
||||
raise AssertionError(
|
||||
f"Expected shape {overall_shape}, got {val.shape}"
|
||||
)
|
||||
meta_chunks = torch.tensor_split(
|
||||
torch.empty(*overall_shape, device="meta"),
|
||||
sections=len(partial_values),
|
||||
dim=arg.split_dim,
|
||||
)
|
||||
|
||||
values_to_cat = []
|
||||
chunk_start_idx = 0
|
||||
if not len(partial_values) == len(meta_chunks):
|
||||
raise AssertionError(
|
||||
f"Expected len(partial_values) == len(meta_chunks), got {len(partial_values)} != {len(meta_chunks)}"
|
||||
)
|
||||
|
||||
for partial_value, meta_chunk in zip(
|
||||
partial_values, meta_chunks, strict=True
|
||||
):
|
||||
chunk_end_idx = chunk_start_idx + meta_chunk.size(arg.split_dim)
|
||||
|
||||
slice_indices = [slice(None, None, None)] * partial_value.ndim
|
||||
slice_indices[arg.split_dim] = slice(chunk_start_idx, chunk_end_idx)
|
||||
sliced = partial_value[slice_indices]
|
||||
values_to_cat.append(sliced)
|
||||
|
||||
chunk_start_idx = chunk_end_idx
|
||||
|
||||
else:
|
||||
values_to_cat = partial_values
|
||||
|
||||
# Validate DTensor consistency: either all values are DTensors
|
||||
# or none are. A mix indicates a bug in the pipeline stage.
|
||||
dtensor_flags = [isinstance(v, DTensor) for v in values_to_cat]
|
||||
if any(dtensor_flags):
|
||||
if not all(dtensor_flags):
|
||||
raise AssertionError(
|
||||
"merge_chunks: expected all values to be DTensors or "
|
||||
"none to be DTensors, got a mix"
|
||||
)
|
||||
# All DTensors must have matching placements.
|
||||
placements = values_to_cat[0].placements
|
||||
for i, v in enumerate(values_to_cat[1:], 1):
|
||||
if v.placements != placements:
|
||||
raise AssertionError(
|
||||
f"merge_chunks: placement mismatch at chunk {i}: "
|
||||
f"expected {placements}, got {v.placements}"
|
||||
)
|
||||
cat_fn = local_map(
|
||||
lambda *chunks: torch.cat(chunks, dim=arg.split_dim),
|
||||
out_placements=(placements,),
|
||||
in_placements=tuple(placements for _ in range(len(values_to_cat))),
|
||||
)
|
||||
args_flattened.append(cat_fn(*values_to_cat))
|
||||
else:
|
||||
args_flattened.append(torch.cat(values_to_cat, dim=arg.split_dim))
|
||||
elif isinstance(arg, _CustomReducer):
|
||||
reduced_val = arg.init_value
|
||||
|
||||
for chunk_idx in range(len(chunks_flattened)):
|
||||
reduced_val = arg.reduce_fn(
|
||||
reduced_val, chunks_flattened[chunk_idx][arg_idx]
|
||||
)
|
||||
|
||||
args_flattened.append(reduced_val)
|
||||
else:
|
||||
value = chunks_flattened[0][arg_idx]
|
||||
for chunk_idx in range(1, len(chunks_flattened)):
|
||||
if not chunks_flattened[chunk_idx][arg_idx] == value:
|
||||
raise AssertionError(
|
||||
f"Expected {value}, got {chunks_flattened[chunk_idx][arg_idx]}"
|
||||
)
|
||||
args_flattened.append(value)
|
||||
|
||||
# Stage 4: Unflatten combined args
|
||||
return tree_unflatten(args_flattened, flatten_spec)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user