Diff Coverage

Diff: origin/master...HEAD, staged and unstaged changes

Source File Diff Coverage (%) Missing Lines
hyper_parallel/compile/parallel_config.py 71.4% 114,118
hyper_parallel/compile/passes/parallel/__init__.py 100%  
hyper_parallel/compile/passes/parallel/pp_pass.py 91.7% 91,107,179,528,542-543,551,557,621,628,665,756,798,836-837,916-919,926-930,943,1003,1043,1078,1081,1088,1183,1192-1193,1206-1207,1224,1236,1241,1367,1371,1520,1557,1561,1579-1580,1583,1619,1649-1650,1656,1658
hyper_parallel/compile/passes/parallel/pp_schedule.py 96.9% 160,276,308
hyper_parallel/compile/passes/pipeline.py 66.7% 83
hyper_parallel/compile/sharding_config.py 87.5% 106,108,277,326-327
hyper_parallel/compile/tracer/graph_tracer.py 92.8% 201,208,212,273,356,362
hyper_parallel/compile/parallel_config.py
110
111
112
113
114
115
116
117
118
119
120
121
            raise ValueError(
                f"fsdp_degree must be None or a positive int, got {self.fsdp_degree}"
            )
        if self.pp_degree is not None and self.pp_degree < 1:
            raise ValueError(
                f"pp_degree must be None or a positive int, got {self.pp_degree}"
            )
        if self.pp_microbatch_size < 1:
            raise ValueError(
                f"pp_microbatch_size must be >= 1, got {self.pp_microbatch_size}"
            )

hyper_parallel/compile/passes/parallel/pp_pass.py
87
88
89
90
91
92
93
94
95
            yield item
        elif isinstance(item, (tuple, list)):
            stack.extend(item)
        elif isinstance(item, dict):
            stack.extend(item.values())


def _tensor_arg_stages(node: fx.Node, node_stage: Dict[fx.Node, int]) -> List[int]:
    """Stages of ``node``'s TENSOR-valued resolved args.
103
104
105
106
107
108
109
110
111
    stages = []
    for arg in _iter_node_args(node):
        stage = node_stage.get(arg)
        if stage is None:
            continue
        val = arg.meta.get("val")
        if val is not None and not isinstance(val, torch.Tensor):
            continue
        stages.append(stage)
175
176
177
178
179
180
181
182
183
        return _even_split(children, pp_degree)

    container_mod = getattr(model, container)
    if isinstance(container_mod, nn.ModuleDict):
        elem_keys = list(container_mod.keys())
    else:
        elem_keys = [str(i) for i in range(len(container_mod))]

    idx = children.index(container)
524
525
526
527
528
529
530
531
532
        model. Copying here would desynchronize the two.
        """
        state_fqns = getattr(graph_module, "state_fqns", None)
        if state_fqns is None:
            raise ValueError(
                "PpPass requires the tracer's state_fqns attribute on the "
                "joint graph (attach it via trace_model_graph)"
            )
        return state_fqns
538
539
540
541
542
543
544
545
546
547
        """Return the graph's live ``state_is_param`` list, defaulting to
        all-params (matching the tracer's fallback for older traces)."""
        state_is_param = getattr(graph_module, "state_is_param", None)
        if state_is_param is None:
            state_is_param = [True] * len(state_fqns)
            graph_module.state_is_param = state_is_param
        return state_is_param

    @staticmethod
    def _loss_node(graph_module: fx.GraphModule) -> Optional[fx.Node]:
547
548
549
550
551
552
553
554
555
    def _loss_node(graph_module: fx.GraphModule) -> Optional[fx.Node]:
        """The original joint graph's loss node (output index 0)."""
        output = next((n for n in graph_module.graph.nodes if n.op == "output"), None)
        if output is None:
            return None
        returned = output.args[0]
        if isinstance(returned, (list, tuple)) and returned:
            first = returned[0]
            if isinstance(first, fx.Node):
553
554
555
556
557
558
559
560
561
        if isinstance(returned, (list, tuple)) and returned:
            first = returned[0]
            if isinstance(first, fx.Node):
                return first
        return None

    # ------------------------------------------------------------------
    # Group / plan resolution
    # ------------------------------------------------------------------
617
618
619
620
621
622
623
624
625
            seen: Set[str] = set()
            for s, fqns in enumerate(manual):
                for fqn in fqns:
                    if fqn in seen:
                        raise ValueError(
                            f"Module '{fqn}' is assigned to multiple stages "
                            f"(stage {s} repeats it)"
                        )
                    seen.add(fqn)
624
625
626
627
628
629
630
631
632
                        )
                    seen.add(fqn)
            stage_plan = [list(fqns) for fqns in manual]
        else:
            stage_plan = _auto_stage_split(model, pp_degree)

        known = dict(model.named_modules())
        for s, fqns in enumerate(stage_plan):
            for fqn in fqns:
661
662
663
664
665
666
667
668
669
        if not unassigned:
            return
        shown = unassigned[:10]
        if len(unassigned) > len(shown):
            shown.append(f"... {len(unassigned) - len(shown)} more")
        _LOG.warning(
            "No PP stage declares modules %s — their nodes ride with the "
            "dataflow attribution (consumer stage, then last stage). "
            "Declare them via PassPlan.pp_stage() if that is not intended.",
752
753
754
755
756
757
758
759
760
            stage_state_indices,
        )

        if input_ph is None or label_ph is None:
            raise ValueError(
                "Joint graph must expose (state..., input, label) placeholders"
            )
        return (
            sorted(stage_state_indices),
794
795
796
797
798
799
800
801
802
            if idx < num_state_inputs:
                stage = self._state_stage(state_fqns[idx], stage_of_fqn)
                state_phs.append(ph)
                if stage is None:
                    deferred_state.append(idx)
                else:
                    node_stage[ph] = stage
                    if stage == stage_idx:
                        stage_state_indices.append(idx)
832
833
834
835
836
837
838
839
840
841
            phase = _BWD if node.meta.get("autograd_backward", False) else _FWD
            if phase == _FWD:
                for arg in _iter_node_args(node):
                    if node_phase.get(arg) == _BWD:
                        phase = _BWD
                        break
            node_phase[node] = phase
            if phase == _FWD:
                stage = self._stage_from_stack(node, stage_of_fqn)
                if stage is None:
912
913
914
915
916
917
918
919
920
921
922
923
        Multi-stage consumption is the RoPE-cache-shaped error case and
        fails with an actionable message.
        """
        for idx in deferred_state:
            ph = state_phs[idx]
            consumer_stages = {node_stage[u] for u in ph.users if u.op != "output"}
            if len(consumer_stages) > 1:
                raise ValueError(
                    f"Root-level state '{state_fqns[idx]}' is consumed by "
                    f"stages {sorted(consumer_stages)} — v1 requires state "
                    f"to live under a single stage's module (e.g. move a "
                    f"shared RoPE cache under a per-stage module or split "
922
923
924
925
926
927
928
929
930
931
932
933
934
                    f"to live under a single stage's module (e.g. move a "
                    f"shared RoPE cache under a per-stage module or split "
                    f"it per stage)"
                )
            stage = next(iter(consumer_stages), 0)
            node_stage[ph] = stage
            if stage == stage_idx:
                stage_state_indices.append(idx)
            _LOG.info(
                "Root-level state '%s' assigned to stage %s by its consumer",
                state_fqns[idx],
                stage,
            )
939
940
941
942
943
944
945
946
947
        for i in range(len(parts) - 1, 0, -1):
            stage = stage_of_fqn.get(".".join(parts[:i]))
            if stage is not None:
                return stage
        return stage_of_fqn.get(fqn)

    def _stage_from_stack(
        self, node: fx.Node, stage_of_fqn: Dict[str, int]
    ) -> Optional[int]:
 999
1000
1001
1002
1003
1004
1005
1006
1007
            if node_phase.get(node) != _BWD:
                continue
            stage = node_stage.get(node)
            if stage is None:
                continue
            for arg in _iter_node_args(node):
                if (
                    isinstance(arg, fx.Node)
                    and node_phase.get(arg) == _BWD
1039
1040
1041
1042
1043
1044
1045
1046
1047
            if node.op == "output":
                continue
            producer = node_stage.get(node)
            if producer is None:
                continue
            for user in node.users:
                if user.op == "output":
                    continue
                consumer = node_stage.get(user)
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
        activation, replayed from the forward outputs).
        """
        output = next((n for n in graph_module.graph.nodes if n.op == "output"), None)
        if output is None or model is None:
            return
        returned = output.args[0]
        if not isinstance(returned, (list, tuple)):
            return
        state_is_param = getattr(
            graph_module, "state_is_param", [True] * len(state_fqns)
        )
        trainable = self._trainable_state_indices(state_fqns, state_is_param, model)
1084
1085
1086
1087
1088
1089
1090
1091
        )
        trainable = self._trainable_state_indices(state_fqns, state_is_param, model)
        for i, grad_node in enumerate(returned[1:]):
            if i >= len(trainable) or not isinstance(grad_node, fx.Node):
                continue
            pstage = self._state_stage(state_fqns[trainable[i]], stage_of_fqn)
            if pstage is not None:
                node_stage[grad_node] = pstage
1179
1180
1181
1182
1183
1184
1185
1186
1187
                # inputs (e.g. gradient-shape sym_size scalars).
                if node_phase.get(arg) != _FWD and arg not in act_in_set:
                    continue
                if arg.op == "placeholder" and arg not in stage_owned_phs:
                    continue
                seen.add(arg)
                saved.append(arg)
        # Fwd-phase values this stage must ship BACKWARD (consumed by the
        # previous stage's backward) ride the fwd outputs as pass-throughs:
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
        # computed once in the fwd subgraph, replayed into the bwd
        # subgraph, and appended to its outputs.
        for v in grad_out_list:
            if node_phase[v] == _FWD and v not in seen:
                seen.add(v)
                saved.append(v)
        return act_in_list, act_out_list, grad_in_list, grad_out_list, saved

    # ------------------------------------------------------------------
    # Subgraph construction
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
        """Return ``name`` (or a suffixed variant) not yet in ``used``."""
        candidate = name
        suffix = 1
        while candidate in used:
            candidate = f"{name}_{suffix}"
            suffix += 1
        used.add(candidate)
        return candidate

    def _copy_node(
1220
1221
1222
1223
1224
1225
1226
1227
        ``env`` maps already-copied same-slice nodes; ``foreign`` maps
        boundary values to their placeholder stand-ins.
        """
        if node.op == "get_attr":
            raise ValueError(
                f"Unexpected get_attr node '{node.name}' — the tracer "
                f"contracts parameters/buffers to be static placeholders"
            )
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
                if a in env:
                    return env[a]
                if a in foreign:
                    return foreign[a]
                raise ValueError(
                    f"Node '{node.name}' references '{a.name}' which lies "
                    f"outside this stage's slice — boundary detection or "
                    f"the stage plan is inconsistent"
                )
            return a

        new_args = fx.map_arg(node.args, map_fn)
        new_kwargs = fx.map_arg(node.kwargs, map_fn)
        new_node = g.create_node(node.op, node.target, new_args, new_kwargs)
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
    ) -> List[fx.Node]:
        """Order the fwd subgraph's outputs: boundary values first, then saved."""
        if is_last:
            if loss_node is None:
                raise ValueError("Last PP stage requires a loss node in the output")
            out_orig: List[fx.Node] = [loss_node]
        else:
            if not act_out_list:
                raise ValueError(
                    "Stage ships no boundary values but is not the last "
                    "stage — the stage plan does not cut the graph"
                )
            out_orig = list(act_out_list)
1516
1517
1518
1519
1520
1521
1522
1523
        returned = output.args[0] if output is not None else []
        stage_grad_nodes: List[fx.Node] = []
        for i, grad_node in enumerate(returned[1:]):
            if i >= len(trainable_indices):
                break
            if trainable_indices[i] in stage_state_set:
                stage_grad_nodes.append(grad_node)
        return stage_grad_nodes
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
            """
            children = child._modules  # pylint: disable=protected-access
            keys = list(children.keys())
            if all(is_kept(f"{cfqn}.{key}") for key in keys):
                return
            for key in keys:
                elem_fqn = f"{cfqn}.{key}"
                if is_kept(elem_fqn):
                    continue
                elem = children[key]
                descendants = [f for f, _ in elem.named_modules() if f]
                if any(is_kept(f"{elem_fqn}.{f}") for f in descendants):
                    prune(elem, elem_fqn + ".")
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
                # Leaf module (e.g. a bare Linear): keep only if declared.
                if not is_kept(cfqn):
                    setattr(mod, name, None)
                return
            if not any(is_kept(f"{cfqn}.{f}") for f in descendants):
                setattr(mod, name, None)
            else:
                # Mixed subtree (manual plans may cut inside a child).
                prune(child, cfqn + ".")

        def prune(mod: nn.Module, prefix: str) -> None:
            """Recursively prune non-stage children under ``prefix``."""
            for name, child in list(mod.named_children()):
1615
1616
1617
1618
1619
1620
1621
1622
1623
        )
        trainable: List[int] = []
        for idx, fqn in enumerate(state_fqns):
            if idx < len(state_is_param) and not state_is_param[idx]:
                continue
            param = param_lookup.get(fqn)
            if param is not None and param.requires_grad:
                trainable.append(idx)
        return trainable
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
                # Training uses static shapes per compile, so the hinted
                # concrete values are stable.
                shape = tuple(int(s) for s in val.shape)
                spec.append(("tensor", shape, val.dtype, val.device))
            elif val is None:
                raise ValueError(
                    f"Boundary {kind} value '{v.name}' on stage {stage_idx} "
                    f"has no 'val' meta (traced without FakeTensor "
                    f"metadata) — cannot size the P2P receive buffer"
                )
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
                    f"has no 'val' meta (traced without FakeTensor "
                    f"metadata) — cannot size the P2P receive buffer"
                )
            else:
                spec.append(("scalar",))
        if nodes and not any(entry[0] == "tensor" for entry in spec):
            raise ValueError(
                f"Boundary {kind} list on stage {stage_idx} carries only "
                f"scalar values — no tensor entry to anchor the P2P "
                f"receive device on"
            )
hyper_parallel/compile/passes/parallel/pp_schedule.py
156
157
158
159
160
161
162
163
164
        label_batch = flat_inputs[-1]

        batch_size = input_batch.shape[0]
        if batch_size % self.microbatch_size != 0:
            raise ValueError(
                f"PP microbatch mismatch: batch size {batch_size} is not "
                f"divisible by pp_microbatch_size {self.microbatch_size}"
            )
        num_microbatches = batch_size // self.microbatch_size
272
273
274
275
276
277
278
279
280
        for value in values:
            if isinstance(value, torch.Tensor):
                tensor = value.contiguous()
            else:
                tensor = torch.tensor(value, dtype=torch.int64, device=anchor_device)
            work = dist.isend(tensor, dst=dst_rank, group=self.pp_group)
            self._pending_sends.append((work, tensor))

    def _recv_values(self, spec: Sequence[Tuple[Any, ...]], src: int) -> List[Any]:
304
305
306
307
308
309
310
311
312

    def _global_rank(self, stage_idx: int) -> int:
        """Map a stage index to its global rank within the PP group."""
        if self.pp_group is None:
            return stage_idx
        return dist.get_global_rank(self.pp_group, stage_idx)


__all__ = ["ScheduleGPipe"]
hyper_parallel/compile/passes/pipeline.py
79
80
81
82
83
84
85
86
87
        # 2. Execution layer: Parallel dimension partitioning
        if getattr(self.config, "fsdp_enabled", False):
            self.passes.append(FSDPPass(pass_plan=self.pass_plan))
        if getattr(self.config, "pp_enabled", False):
            self.passes.append(PpPass(pass_plan=self.pass_plan))

        # 3. Communication-compute overlap optimization
        if getattr(self.config, "enable_overlap", False):
            self.passes.append(AutoOverlapPass())
hyper_parallel/compile/sharding_config.py
102
103
104
105
106
107
108
109
110
111
112
        merged = PassPlan()
        merged.fsdp_modules = {**self.fsdp_modules, **other.fsdp_modules}
        merged.fsdp_patterns = {**self.fsdp_patterns, **other.fsdp_patterns}
        if other.pp_module_fqns_per_stage is not None:
            merged.pp_module_fqns_per_stage = other.pp_module_fqns_per_stage
        elif self.pp_module_fqns_per_stage is not None:
            merged.pp_module_fqns_per_stage = self.pp_module_fqns_per_stage
        return merged

    def fsdp_wrap(self, module_fqn: str) -> "PassPlan":
        """Mark a specific module for FSDP wrapping (exact match).
273
274
275
276
277
278
279
280
281
    section = config.get(key) or {}
    if not isinstance(section, dict):
        # A present-but-empty section parses to None and is normalized to {}
        # above, so anything landing here is a real scalar/sequence typo.
        raise ValueError(
            f"YAML '{key}' section must be a mapping (e.g. nested keys or an "
            f"empty section); got {type(section).__name__} in {config_path}"
        )
    return section
322
323
324
325
326
327
328
329
330
331
        if isinstance(stage, dict):
            stage_idx = stage.get("stage", idx)
            module_fqns = list(stage.get("modules", []))
        else:
            stage_idx = idx
            module_fqns = list(stage)
        plan.pp_stage(stage_idx, module_fqns)


def create_simple_sharding_plan() -> PassPlan:
hyper_parallel/compile/tracer/graph_tracer.py
197
198
199
200
201
202
203
204
205
            yield a
        elif isinstance(a, (tuple, list)):
            for x in a:
                if isinstance(x, torch.fx.Node):
                    yield x


def _output_args(fx_g: torch.fx.GraphModule) -> List[torch.fx.Node]:
    """Return the flattened forward arg list of the graph's ``output`` node."""
204
205
206
207
208
209
210
211
212
213
214
215
216
def _output_args(fx_g: torch.fx.GraphModule) -> List[torch.fx.Node]:
    """Return the flattened forward arg list of the graph's ``output`` node."""
    out_node = next((n for n in fx_g.graph.nodes if n.op == "output"), None)
    if out_node is None:
        return []
    args: List[torch.fx.Node] = []
    for a in out_node.args:
        if isinstance(a, torch.fx.Node):
            args.append(a)
        elif isinstance(a, (tuple, list)):
            for x in a:
                if isinstance(x, torch.fx.Node):
                    args.append(x)
269
270
271
272
273
274
275
276
277
      nodes or the loss source) spans exactly the backward half.
    """
    output = _output_args(fx_g)
    if not output:
        return
    loss_node = output[0]
    grad_seeds = output[1:]

    # Forward loss chain: everything that flows INTO the loss. Backward is
352
353
354
355
356
357
358
359
360
def _copy_fwd_meta(fwd_node: torch.fx.Node, node: torch.fx.Node) -> None:
    """Copy ``custom``/``nn_module_stack``/``stack_trace`` from fwd to bwd."""
    custom = fwd_node.meta.get("custom")
    if custom:
        node.meta.setdefault("custom", {}).update(copy.deepcopy(custom))
    nn_module_stack = fwd_node.meta.get("nn_module_stack")
    if nn_module_stack is not None:
        node.meta["nn_module_stack"] = nn_module_stack.copy()
    stack_trace = fwd_node.meta.get("stack_trace")
358
359
360
361
362
363
364
365
366
    if nn_module_stack is not None:
        node.meta["nn_module_stack"] = nn_module_stack.copy()
    stack_trace = fwd_node.meta.get("stack_trace")
    if stack_trace is not None:
        node.meta["stack_trace"] = stack_trace


def _fakeify_input(fake_mode: FakeTensorMode, x: Any) -> Any:
    """Convert a real tensor input into its fake counterpart."""