Diff Coverage

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

Source File Diff Coverage (%) Missing Lines
hyper_parallel/core/pipeline_parallel/scheduler.py 0.0% 233-234
hyper_parallel/core/pipeline_parallel/stage.py 4.3% 55-56,62-63,196,200-201,205,217,222-223,238-239,242-243,248-249,340,346,348,353,355
hyper_parallel/core/shard/ops/parallel_elementwise.py 44.4% 31-32,678,681,684
hyper_parallel/core/tensor_parallel/style.py 68.8% 45,392-393,762-763
hyper_parallel/data/__init__.py 100%  
hyper_parallel/data/dummy.py 42.9% 44-47,50,53-55,65-72
hyper_parallel/data/hf.py 34.7% 50,53,56-57,65,67-69,71-76,81-85,96,99,101-102,111-112,114,120-121,125,129,135,141
hyper_parallel/data/megatron/__init__.py 100%  
hyper_parallel/data/megatron/blendable_dataset.py 22.2% 54-55,59-65,69-71,76-80,84-86,89-92,120-125,128,134,137-139
hyper_parallel/data/megatron/builder.py 15.9% 56-58,60-62,64,66-67,71-74,85-96,102-107,109,113-120,122-124,130-133,138,142,145-150,154,161,165
hyper_parallel/data/megatron/gpt_dataset.py 16.8% 66-68,86-88,93,95-106,108-110,113-119,124-126,137,155,158,199-202,204-210,212,216,220-223,226-231,233-235,238-239,241-242,247-249,251-252,254-256,261-266,275-277,280-288,292,295,299-304,306-315,317-318,322,325-331
hyper_parallel/data/megatron/indexed_dataset.py 27.1% 82,87,92,101-103,107,112-115,128-133,137-139,143-146,151,154,157,160-161,165,192-198,200-203,210,215,220,225,240-244,248-249,254,257,270,274-275,278-283,308-309,311-319,323-327,331,342,345-359,363
hyper_parallel/data/preset_pt.py 22.5% 37,40,43,56-63,67,81-87,101-106,110-124,129-131,143-148,150-155,157,159-163,167
hyper_parallel/data/registry.py 62.1% 56,60,71-75,83,86,89,108
hyper_parallel/data/vl_dummy.py 15.7% 50-60,63,66-69,73-76,81-85,101-102,106-118,122-124,145-147,149-168,170,176-177,181
hyper_parallel/platform/mindspore/platform.py 66.7% 1179
hyper_parallel/platform/platform.py 66.7% 266
hyper_parallel/platform/torch/platform.py 66.7% 902
hyper_parallel/trainer/base.py 24.3% 144-145,149-157,165,185-186,196,200,207,222-223,234-235,241-245,330-332,337,343-344,361,366-367,378,382-383,386-399,401-402,424-427,449-450,454,467,469-471,496-498,525-526,531,541-542,547,552-554,564-573,583-585,590-592,600,603-606,621,624-625,632-633,637,643-645,648-654,656,662,667-668,670-671,712,768,776-779,781-785,787-790,796,798-806,809,811-817,820-826,829-831,833-848,850-851,855-864,966,1171,1182,1197-1200,1206,1230,1235,1246,1249,1329-1335,1338-1346,1348-1350,1352,1359-1360,1363-1372,1379-1381,1415,1432,1449-1451,1458-1460,1469,1474-1479,1484-1485,1500,1553-1563,1569-1570,1593-1595,1599-1601,1603-1609,1612-1615,1618-1624,1628-1636,1640-1641,1643-1647,1651-1659,1663,1668-1669,1673-1675,1677-1682,1686-1692,1696-1704,1712-1715,1719-1728,1732-1736,1740-1750,1754-1762,1782-1792,1821,1903-1904,1908-1911,1994,2008-2011,2013-2021,2034,2038-2043,2123-2138,2155-2159,2168,2227,2247
hyper_parallel/trainer/callbacks/base.py 100%  
hyper_parallel/trainer/config.py 100%  
hyper_parallel/trainer/llm_trainer.py 0.0% 55-58,82,216,221-222,243,247-248,251-264,266-267
hyper_parallel/trainer/parallel_dims.py 85.7% 503
hyper_parallel/trainer/vl_trainer.py 0.0% 42,77-82,84-89,91-95,97,99,104-117,121
hyper_parallel/core/pipeline_parallel/scheduler.py
229
230
231
232
233
234
235
236
237
238
def _exec_fsdp_unshard(stage):
    """Unshard every HSDPModule in the stage's submodule tree."""
    for _, module in platform.get_cells_and_names(stage.submodule):
        if isinstance(module, HSDPModule):
            if getattr(module, "_hp_pp_lazy_unshard_in_forward", False):
                continue
            module.unshard()


def _exec_fsdp_reshard(stage):
hyper_parallel/core/pipeline_parallel/stage.py
51
52
53
54
55
56
57
58
59
60
            raise TypeError(f"Argument 'shared_stage' must be list or tuple, \
                             but got type {type(shared_stage)}.")
        self._shared_stage = shared_stage
        self._parameter = parameter
        self._owner_module = owner_module
        self._param_name = param_name
        self.group = None

    @property
    def parameter(self):
58
59
60
61
62
63
64
65
66
67

    @property
    def parameter(self):
        """Return the shared parameter object."""
        if self._owner_module is not None and self._param_name is not None:
            return getattr(self._owner_module, self._param_name)
        return self._parameter

    @property
    def shared_stage(self):
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
            param = shared_param_info.parameter
            # Under FSDP the shared weight is a sharded DTensor; sync the local
            # shard (each rank's PP-line group covers the matching dp shards of
            # the two tied ends).
            data = param.to_local() if isinstance(param, DTensor) else param
            # On the meta-init -> FSDP-wrap -> load path the storage is still on
            # meta here; the trainer re-runs this after materialization, so skip
            # until it is real (broadcasting a meta tensor is invalid).
            if getattr(data, "is_meta", False):
                continue
            shared_stage = shared_param_info.shared_stage
            group, group_ranks = self._init_shared_parameter_group(shared_stage)
            shared_param_info.group = group
            platform.broadcast(data, group_ranks[0], group)

    def _global_rank(self, stage_index):
        real_stage_num = self.stage_num // self._virtual_chunk_num
        real_stage_index = stage_index % real_stage_num
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
        return platform.get_global_rank(self.pp_group, real_stage_index)

    def _init_shared_parameter_group(self, shared_stage):
        """init group of shared parameter."""
        group_ranks = [self._global_rank(stage) for stage in shared_stage]
        # When the shared stages span the whole PP sub-mesh (the tied-embedding
        # pp=2 case), reuse the PP group: it is already created per dp line, so
        # this avoids a world-collective ``new_group`` that would deadlock under
        # FSDP where each dp line needs its own {stage0, stage1} group.
        if self.mesh is not None and len(group_ranks) == self.mesh.size():
            return self.mesh.get_group(), group_ranks
        group = platform.create_group(group_ranks)
        return group, group_ranks

    def sync_shared_parameters_grad(self):
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
                continue
            grad = param.grad
            # A tied end whose backward has not populated a grad has nothing to
            # contribute to the cross-stage sum; skip it.
            if grad is None:
                continue
            # Under FSDP the grad is a sharded DTensor; all-reduce the local
            # shard so the tied ends' matching dp shards sum to one gradient.
            if isinstance(grad, DTensor):
                grad = grad.to_local()
            group = shared_param_info.group
            # ``group`` is assigned by ``_sync_shared_parameters`` once the
            # parameter is materialized off meta; if that handshake has not run
            # there is no peer to reduce with yet, so skip.
            if group is None:
                continue
            # platform.all_reduce expects group_info (with .group for Torch, or str for MindSpore)
            group_info = group if isinstance(group, str) else SimpleNamespace(group=group)
            platform.all_reduce(grad, group_info)
336
337
338
339
340
341
342
343
344

    def get_last_stage_sens(self, last_stage_outputs):
        """Get last stage sens"""
        p_sens = None
        loss_scale = float(getattr(self, "loss_scale", 1.0))
        if isinstance(last_stage_outputs, (list, tuple)):
            p_sens = []
            for _, out_i in enumerate(last_stage_outputs):
                if isinstance(out_i, DTensor):
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
            p_sens = []
            for _, out_i in enumerate(last_stage_outputs):
                if isinstance(out_i, DTensor):
                    repeat_num = out_i.layout.repeat_num()
                    sens_i = platform.full_like(out_i.to_local(), loss_scale / repeat_num)
                else:
                    sens_i = platform.full_like(out_i, loss_scale)
                p_sens.append(sens_i)
        else:
            if isinstance(last_stage_outputs, DTensor):
                repeat_num = last_stage_outputs.layout.repeat_num()
                p_sens = platform.full_like(last_stage_outputs.to_local(), loss_scale / repeat_num)
            else:
                p_sens = platform.full_like(last_stage_outputs, loss_scale)

        return p_sens

    def _construct_forward_recv_info(self, micro_index, idx, global_rank, meta):
hyper_parallel/core/shard/ops/parallel_elementwise.py
27
28
29
30
31
32
33
34
35
36


def _straight_through_scale(value, scale):
    """Scale the forward value while preserving the input gradient when supported."""
    delta = value / scale - value
    return platform.stop_gradient(delta) + value


def _unwrap_local_value(value):
    """Convert DTensor-like values to local tensors while preserving containers."""
674
675
676
677
678
679
680
681
682
683
684
685
686
        # that ``AllReduce(SUM)`` of the Partial output reconstructs
        # ``x1 + true_x2`` while keeping the Replicate gradient unscaled.

        def _scaled_forward_only(x):
            return _straight_through_scale(x, scaling_factor)

        def _expand_impl1(x1, x2, *args, **kwargs):
            return func(_scaled_forward_only(x1), x2, *args, **kwargs)

        def _expand_impl2(x1, x2, *args, **kwargs):
            return func(x1, _scaled_forward_only(x2), *args, **kwargs)

        return _expand_impl1 if not x1_partial else _expand_impl2
hyper_parallel/core/tensor_parallel/style.py
41
42
43
44
45
46
47
48
49

def _src_data_rank_for_tensor(tensor: Any, src_data_rank: Optional[int]) -> Optional[int]:
    """Disable source-rank communication while sharding meta tensors."""
    if getattr(tensor, "is_meta", False):
        return None
    return src_data_rank


__all__ = [
388
389
390
391
392
393
394
395
396
397
                    "RowwiseParallel expects a DTensor from Linear outputs; "
                    f"got {type(outputs)}. If this is an unsupported module, extend I/O hooks."
                )
        if reduce_dtype is not None and tuple(outputs.placements) != tuple(output_layouts):
            local_output = platform.cast_fp_tensor(reduce_dtype, outputs.to_local())
            outputs = DTensor.from_local(local_output, outputs.device_mesh, outputs.placements)
        if tuple(outputs.placements) != tuple(output_layouts):
            outputs = outputs.redistribute(device_mesh, output_layouts)
        if use_local_output:
            return outputs.to_local()
758
759
760
761
762
763
764
765
766
767
                    dt_out = out
                else:
                    dt_out = DTensor.from_local(out, device_mesh, (out_layout,))
                if self.reduce_dtype is not None and out_layout != desired_out_layout:
                    local_out = platform.cast_fp_tensor(self.reduce_dtype, dt_out.to_local())
                    dt_out = DTensor.from_local(local_out, dt_out.device_mesh, dt_out.placements)
                if out_layout != desired_out_layout:
                    dt_out = dt_out.redistribute(device_mesh, (desired_out_layout,))
                prepared_outputs.append(
                    dt_out.to_local() if self.use_local_output else dt_out
hyper_parallel/data/dummy.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
        base_seed: Seed offset; each sample uses ``base_seed + idx``.
    """

    def __init__(self, num_samples: int, seq_length: int, vocab: int, base_seed: int = 42) -> None:
        self.num_samples = int(num_samples)
        self.seq_length = int(seq_length)
        self.vocab = int(vocab)
        self.base_seed = int(base_seed)

    def __len__(self) -> int:
        return self.num_samples

    def __getitem__(self, idx: int) -> Dict[str, Any]:
        g = torch.Generator().manual_seed(self.base_seed + idx)
        input_ids = torch.randint(0, self.vocab, (self.seq_length,), generator=g)
        return {"input_ids": input_ids, "labels": input_ids.clone()}


@DATASET_REGISTRY.register("dummy")
def build_dummy(*, base: Any, args: Any, **_: Any) -> DummyDataset:
61
62
63
64
65
66
67
68
69
70
71
72

    Picks ``vocab`` from ``model.config.vocab_size`` when available, else
    defaults to 32000 (matches the pre-refactor behaviour in BaseTrainer).
    """
    seq_len = getattr(args.data, "max_seq_len", 2048)
    model_cfg = getattr(getattr(base, "model", None), "config", None)
    vocab_size = getattr(model_cfg, "vocab_size", 32000) if model_cfg else 32000
    base_seed = getattr(args.train, "seed", 42)
    total_samples = base.state.max_steps * getattr(args.train, "global_batch_size", 8)
    ds = DummyDataset(total_samples, seq_len, vocab_size, base_seed=base_seed)
    logger.info("Dummy dataset created: %d samples, seq_len=%d", total_samples, seq_len)
    return ds
hyper_parallel/data/hf.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
    actually pulls a sample.
    """

    def __init__(self, hf_ds: Any) -> None:
        self.data = hf_ds

    def __len__(self) -> int:
        return len(self.data)

    def __getitem__(self, idx: int):
        item = self.data[idx]
        return {
            "input_ids": torch.tensor(item["input_ids"], dtype=torch.long),
            "labels": torch.tensor(item["labels"], dtype=torch.long),
        }
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89


def _load_raw(args: Any, data_type: str) -> Any:
    """Run the appropriate ``load_dataset`` call for ``data_type``."""
    from datasets import load_dataset  # pylint: disable=C0415  # optional dep

    train_path = args.data.train_path
    if not train_path:
        raise ValueError(f"data.train_path is required when data.type='{data_type}'")

    if data_type == "json_file":
        return load_dataset("json", data_files=train_path, split="train")
    subset = getattr(args.data, "subset", None)
    if subset:
        return load_dataset(train_path, subset, split="train")
    return load_dataset(train_path, split="train")


def _maybe_truncate(ds: Any, args: Any) -> Any:
    """Apply ``data.train_size`` if it shrinks the dataset."""
    train_size = getattr(args.data, "train_size", None)
    if train_size and train_size < len(ds):
        ds = ds.select(range(train_size))
        logger.info("Dataset truncated to %d samples", train_size)
    return ds


def _build_hf(*, base: Any, args: Any, data_transform: Any, data_type: str, **_: Any) -> TokenizedDataset:
    """Shared loader for ``hf_datasets`` and ``json_file``.
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
    sequences and wraps in :class:`TokenizedDataset`. Updates
    ``base.state.max_steps`` to ``min(cfg.max_steps, len/global_bs)`` so
    epoch boundaries stay consistent with the data on hand.
    """
    logger.info(
        "Loading dataset: type=%s, path=%s", data_type, args.data.train_path,
    )
    ds = _maybe_truncate(_load_raw(args, data_type), args)

    if data_transform is not None:
        ds = ds.map(
            data_transform,
            batched=True,
            remove_columns=ds.column_names,
            desc="Tokenizing",
107
108
109
110
111
112
113
114
115
116
117
118
        )
    # Drop empty rows only when the dataset is already tokenized — without a
    # transform a raw text dataset has no ``input_ids`` column and the filter
    # predicate would raise ``KeyError`` instead of loading.
    if "input_ids" in ds.column_names:
        ds = ds.filter(lambda x: len(x["input_ids"]) > 0)

    wrapped = TokenizedDataset(ds)
    # max_steps clipping must happen pre-dataloader so the train loop's
    # epoch count matches the data on hand; the trainer reads
    # ``base.state.max_steps`` further down the build chain. The cap is
    # the TOTAL step budget across all epochs — clipping to a single
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
    # epoch count matches the data on hand; the trainer reads
    # ``base.state.max_steps`` further down the build chain. The cap is
    # the TOTAL step budget across all epochs — clipping to a single
    # epoch would silently truncate multi-epoch training.
    num_epochs = max(int(getattr(args.train, "num_train_epochs", 1) or 1), 1)
    base.state.max_steps = min(
        args.train.max_steps,
        num_epochs * (len(wrapped) // max(args.train.global_batch_size, 1)),
    )
    logger.info(
        "Dataset ready: %d samples, max_steps=%d",
        len(wrapped), base.state.max_steps,
    )
    return wrapped


@DATASET_REGISTRY.register("hf_datasets")
def build_hf_datasets(**kwargs: Any) -> TokenizedDataset:
131
132
133
134
135
136
137
138
139

@DATASET_REGISTRY.register("hf_datasets")
def build_hf_datasets(**kwargs: Any) -> TokenizedDataset:
    """Build an HF hub / arrow dataset."""
    return _build_hf(data_type="hf_datasets", **kwargs)


@DATASET_REGISTRY.register("json_file")
def build_json_file(**kwargs: Any) -> TokenizedDataset:
137
138
139
140
141

@DATASET_REGISTRY.register("json_file")
def build_json_file(**kwargs: Any) -> TokenizedDataset:
    """Build a local Alpaca-style ``.json`` / ``.jsonl`` dataset."""
    return _build_hf(data_type="json_file", **kwargs)
hyper_parallel/data/megatron/blendable_dataset.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96

    ``seed`` is accepted for API parity but unused; the algorithm is
    deterministic in ``(weights, num_samples)``.
    """
    if len(dataset_sizes) != len(weights):
        raise ValueError(
            f"dataset_sizes ({len(dataset_sizes)}) and weights ({len(weights)}) "
            f"must have the same length"
        )
    if any(w < 0 for w in weights):
        raise ValueError(f"weights must be non-negative, got {list(weights)}")
    total_w = float(sum(weights))
    if total_w <= 0:
        raise ValueError("weights must contain at least one non-zero value")
    norm_w = np.asarray([w / total_w for w in weights], dtype=np.float64)
    sizes = np.asarray([int(s) for s in dataset_sizes], dtype=np.int64)
    # A zero-length source with a non-zero weight would be selected by the
    # greatest-error rule and then hit ``counters[d] % sizes[d]`` → divide by
    # zero. Reject up-front (a source contributing samples must have samples).
    empty_weighted = [i for i, (s, w) in enumerate(zip(sizes, weights)) if s == 0 and w > 0]
    if empty_weighted:
        raise ValueError(
            f"BlendableDataset sources {empty_weighted} have a non-zero weight "
            f"but zero length; an empty source cannot contribute samples"
        )

    dataset_index = np.zeros(num_samples, dtype=np.int32)
    dataset_sample_index = np.zeros(num_samples, dtype=np.int64)
    realised = np.zeros(len(weights), dtype=np.float64)
    counters = np.zeros(len(weights), dtype=np.int64)
    for g in range(num_samples):
        # error[d] = how far behind source d's realised fraction is —
        # picking the argmax keeps every source within 1 sample of its
        # target share at every prefix (Megatron's invariant).
        error = norm_w * (g + 1) - realised
        d = int(np.argmax(error))
        dataset_index[g] = d
        # Modulo wrap so a small sub-dataset reused inside a long blend
        # cycles through its samples in order instead of indexing OOB.
        dataset_sample_index[g] = counters[d] % sizes[d]
        realised[d] += 1.0
        counters[d] += 1
    return dataset_index, dataset_sample_index


class BlendableDataset(Dataset):
    """Weighted blend over a list of datasets.
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
        weights: Sequence[float],
        num_samples: int,
        seed: int = 1234,
    ) -> None:
        if not datasets:
            raise ValueError("BlendableDataset requires at least one dataset")
        self.datasets = datasets
        sizes = [len(d) for d in datasets]
        self.num_samples = int(num_samples)
        self.dataset_index, self.dataset_sample_index = _build_blend_indices(
            sizes, weights, self.num_samples, seed,
        )
        logger.info(
            "BlendableDataset built: %d sub-datasets, weights=%s, total samples=%d",
            len(datasets), list(weights), self.num_samples,
        )
130
131
132
133
134
135
136
137
138
139
            len(datasets), list(weights), self.num_samples,
        )

    def __len__(self) -> int:
        return self.num_samples

    def __getitem__(self, idx: int) -> Dict[str, Any]:
        d = int(self.dataset_index[idx])
        s = int(self.dataset_sample_index[idx])
        return self.datasets[d][s]
hyper_parallel/data/megatron/builder.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
    - ``str`` of whitespace-separated tokens: ``"0.3 /data/a 0.7 /data/b"``
    - ``list[float | str]`` in the same alternating order
    - ``list[list]`` of ``[weight, prefix]`` pairs
    """
    if isinstance(raw, str):
        toks = raw.split()
    elif isinstance(raw, (list, tuple)):
        # Already in pair form?
        if raw and isinstance(raw[0], (list, tuple)):
            return [(float(w), str(p)) for w, p in raw]
        toks = list(raw)
    else:
        raise ValueError(f"Unsupported blend spec type: {type(raw)}")

    if len(toks) % 2 != 0:
        raise ValueError(
            f"Blend spec must have an even number of tokens "
            f"(alternating weight + prefix); got {len(toks)}: {toks}"
        )
    out: List[Tuple[float, str]] = []
    for i in range(0, len(toks), 2):
        out.append((float(toks[i]), str(toks[i + 1])))
    return out


def _looks_like_blend(train_path: Any) -> bool:
    """Return ``True`` when ``train_path`` is a multi-source blend spec.
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
    a list of pairs); a single corpus is a bare path-prefix. Keying off "the
    first token parses as a float" (rather than "contains whitespace") lets a
    single path that legitimately contains spaces still be read as one source.
    """
    if isinstance(train_path, (list, tuple)):
        return True
    if not isinstance(train_path, str):
        return False
    toks = train_path.split()
    if len(toks) < 2:
        return False
    try:
        float(toks[0])
    except ValueError:
        return False
    return True


@DATASET_REGISTRY.register("megatron")
def build_megatron(*, base: Any, args: Any, **_: Any) -> Any:
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127

@DATASET_REGISTRY.register("megatron")
def build_megatron(*, base: Any, args: Any, **_: Any) -> Any:
    """Build a Megatron ``.bin``/``.idx`` dataset (single source or blend)."""
    del base
    data_cfg = args.data
    train_cfg = args.train
    train_path = getattr(data_cfg, "train_path", None)
    if not train_path:
        raise ValueError("data.train_path is required when data.type='megatron'")

    seq_length = int(getattr(data_cfg, "max_seq_len", 2048))
    # ``megatron_seed`` may be present-but-None on a default DataConfig; fall
    # through to ``train.seed`` in that case so the typed config doesn't have
    # to invent a sentinel.
    megatron_seed = getattr(data_cfg, "megatron_seed", None)
    if megatron_seed is None:
        megatron_seed = getattr(train_cfg, "seed", 1234)
    seed = int(megatron_seed)
    pad_token_id = int(getattr(data_cfg, "pad_token_id", 0))
    eod_token_id = getattr(data_cfg, "eod_token_id", None)
    eod_mask_loss = bool(getattr(data_cfg, "eod_mask_loss", False))
    mmap_bin = bool(getattr(data_cfg, "mmap_bin_files", True))

    num_samples = int(train_cfg.max_steps * train_cfg.global_batch_size)
    if num_samples <= 0:
        raise ValueError(
            f"num_samples = max_steps * global_batch_size must be > 0; "
            f"got max_steps={train_cfg.max_steps}, global_bs={train_cfg.global_batch_size}"
        )
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
            f"got max_steps={train_cfg.max_steps}, global_bs={train_cfg.global_batch_size}"
        )

    # Single source: simplest path, no blend needed.
    if not _looks_like_blend(train_path):
        prefix = strip_suffix(train_path)
        idx_ds = IndexedDataset(prefix, mmap=mmap_bin)
        gpt_ds = GPTDataset(
            idx_ds, num_samples=num_samples, seq_length=seq_length,
            seed=seed, pad_token_id=pad_token_id,
            eod_mask_loss=eod_mask_loss, eod_token_id=eod_token_id,
        )
        logger.info(
            "Megatron dataset built: prefix=%s docs=%d sequences=%d samples=%d seq_len=%d",
            prefix, idx_ds.num_documents, len(idx_ds), len(gpt_ds), seq_length,
        )
        return gpt_ds

    # Blend: parse, build per-source GPTDataset, wrap in BlendableDataset.
    pairs = _parse_blend(train_path)
    weights = [w for w, _ in pairs]
    sub_datasets = []
    for w, p in pairs:
        prefix = strip_suffix(p)
        idx_ds = IndexedDataset(prefix, mmap=mmap_bin)
        # Each sub-dataset is sized to cover its share of the blend; we ask
        # for the full ``num_samples`` here because blending picks indices
        # modulo the sub-dataset length.
        sub_datasets.append(
            GPTDataset(
                idx_ds, num_samples=num_samples, seq_length=seq_length,
                seed=seed, pad_token_id=pad_token_id,
                eod_mask_loss=eod_mask_loss, eod_token_id=eod_token_id,
157
158
159
160
161
162
163
164
165
                seed=seed, pad_token_id=pad_token_id,
                eod_mask_loss=eod_mask_loss, eod_token_id=eod_token_id,
            )
        )
        logger.info(
            "  blend source w=%g prefix=%s docs=%d sequences=%d",
            w, prefix, idx_ds.num_documents, len(idx_ds),
        )
    return BlendableDataset(sub_datasets, weights, num_samples=num_samples, seed=seed)
hyper_parallel/data/megatron/gpt_dataset.py
62
63
64
65
66
67
68
69
70
71
72
    appears once — that randomness is part of the canonical training
    distribution and the realised epoch-boundary noise is what Megatron's
    schedulers expect.
    """
    doc_idx = np.tile(np.arange(num_documents, dtype=np.int32), num_epochs)
    rng.shuffle(doc_idx)
    return doc_idx


def _build_sample_index(
    sizes: np.ndarray,
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
    token so labels can be shifted by 1; the helper keeps the
    pre-Megatron-Cython slow-path semantics (no boundary correction —
    multi-document samples are reassembled by :meth:`GPTDataset._read_sample`).
    """
    tokens_to_consume = num_epochs * tokens_per_epoch - 1  # -1 to keep ``+1`` extra token in reach
    sample_stride = seq_length
    num_samples = tokens_to_consume // sample_stride
    # int64: column 0 indexes ``doc_idx`` whose length is
    # ``num_documents * num_epochs`` — int32 would wrap past ~2.1e9 slots on
    # large corpora trained for many epochs, silently selecting the wrong
    # document. Megatron-LM uses int64 here for the same reason.
    sample_idx = np.zeros((num_samples + 1, 2), dtype=np.int64)

    cur_doc_index = 0
    cur_doc_offset = 0
    cur_doc_remaining = int(sizes[doc_idx[cur_doc_index]])
    sample_idx[0, 0] = cur_doc_index
    sample_idx[0, 1] = cur_doc_offset
    for sample_id in range(1, num_samples + 1):
        remaining = sample_stride
        while remaining > 0:
            if cur_doc_remaining > remaining:
                cur_doc_offset += remaining
                cur_doc_remaining -= remaining
                remaining = 0
            else:
                remaining -= cur_doc_remaining
                cur_doc_index += 1
                if cur_doc_index >= doc_idx.shape[0]:
                    # Document stream exhausted — record where we landed
                    # and stop. Caller will pad the tail.
                    cur_doc_remaining = 0
                    break
                cur_doc_offset = 0
                cur_doc_remaining = int(sizes[doc_idx[cur_doc_index]])
        sample_idx[sample_id, 0] = cur_doc_index
        sample_idx[sample_id, 1] = cur_doc_offset
    return sample_idx


def _build_shuffle_index(num_samples: int, rng: np.random.RandomState) -> np.ndarray:
    """Random permutation of ``[0, num_samples)``."""
    shuffle = np.arange(num_samples, dtype=np.int64)
    rng.shuffle(shuffle)
    return shuffle


def _layout_fingerprint(doc_sizes: np.ndarray) -> str:
    """Hash the per-document token-count array.
133
134
135
136
137
138
139
140
141
    corpus regenerated at the same path with a different document layout — even
    one preserving document count and total token count — produces a different
    fingerprint and invalidates the stale cache.
    """
    return hashlib.sha1(np.ascontiguousarray(doc_sizes, dtype=np.int64).tobytes()).hexdigest()[:16]


def _cache_key(
    prefix: str,
151
152
153
154
155
156
157
158
159
160
161
162
    :func:`_layout_fingerprint`) so that regenerating the ``.bin``/``.idx``
    under the same path invalidates a stale cache instead of silently
    replaying indices computed against the old token stream.
    """
    h = hashlib.sha1(
        f"{prefix}|{seq_length}|{num_samples}|{num_epochs}|{seed}|{layout_fp}".encode("utf-8"),
    ).hexdigest()[:16]
    return h


class GPTDataset(Dataset):
    """Megatron-style GPT dataset on top of an :class:`IndexedDataset`.
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
        eod_mask_loss: bool = False,
        eod_token_id: Optional[int] = None,
        cache_dir: Optional[str] = None,
    ) -> None:
        if num_samples <= 0:
            raise ValueError(f"num_samples must be > 0, got {num_samples}")
        if seq_length <= 0:
            raise ValueError(f"seq_length must be > 0, got {seq_length}")

        self.indexed_dataset = indexed_dataset
        self.num_samples = int(num_samples)
        self.seq_length = int(seq_length)
        self.seed = int(seed)
        self.pad_token_id = int(pad_token_id)
        self.eod_mask_loss = bool(eod_mask_loss)
        self.eod_token_id = eod_token_id

        self.document_index, self.sample_index, self.shuffle_index = self._build_indices(cache_dir)
        # Adjacent samples often touch the same document — cache the concat
        # result for the last few requested doc ids so straddling reads
        # don't re-walk the IndexedDataset.
        self._cached_doc = functools.lru_cache(maxsize=128)(self._read_doc)

    def _build_indices(self, cache_dir: Optional[str]) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Build (or load from cache) the three Megatron-style indices."""
        sizes = self.indexed_dataset.sequence_lengths
        num_docs = self.indexed_dataset.num_documents
        if num_docs == 0:
            raise ValueError("IndexedDataset has no documents to sample from")
        # Build a per-document length array using the existing
        # ``document_indices`` boundaries.
        doc_boundaries = self.indexed_dataset.document_indices
        doc_sizes = np.zeros(num_docs, dtype=np.int64)
        for d in range(num_docs):
            start = int(doc_boundaries[d])
            end = int(doc_boundaries[d + 1])
            doc_sizes[d] = int(sizes[start:end].sum())

        tokens_per_epoch = int(doc_sizes.sum())
        if tokens_per_epoch == 0:
            raise ValueError("IndexedDataset has zero total tokens")
        # ``+1`` because each sample reads ``seq_length + 1`` tokens (input
        # plus the next-token label). Ceil so we always cover ``num_samples``.
        tokens_required = self.num_samples * self.seq_length + 1
        num_epochs = max(1, int(np.ceil(tokens_required / tokens_per_epoch)))

        cache_dir = self._resolve_cache_dir(cache_dir)
        key = _cache_key(
            self.indexed_dataset.path_prefix, self.seq_length,
            self.num_samples, num_epochs, self.seed,
            _layout_fingerprint(doc_sizes),
        )
        doc_path = os.path.join(cache_dir, f"doc_idx_{key}.npy")
        sample_path = os.path.join(cache_dir, f"sample_idx_{key}.npy")
        shuffle_path = os.path.join(cache_dir, f"shuffle_idx_{key}.npy")

        if os.path.isfile(doc_path) and os.path.isfile(sample_path) and os.path.isfile(shuffle_path):
            return np.load(doc_path), np.load(sample_path), np.load(shuffle_path)

        rng = np.random.RandomState(self.seed)
        doc_idx = _build_document_index(num_docs, num_epochs, rng)
        sample_idx = _build_sample_index(
            doc_sizes, doc_idx, self.seq_length, tokens_per_epoch, num_epochs,
        )
        # ``sample_idx`` has at most ``num_samples + 1`` rows; clip in case
        # we built slightly more (rounded up by tokens_per_epoch / seq_len).
        usable_samples = max(sample_idx.shape[0] - 1, 1)
        shuffle_idx = _build_shuffle_index(min(self.num_samples, usable_samples), rng)
        np.save(doc_path, doc_idx)
        np.save(sample_path, sample_idx)
        np.save(shuffle_path, shuffle_idx)
        return doc_idx, sample_idx, shuffle_idx

    def _resolve_cache_dir(self, user_cache_dir: Optional[str]) -> str:
        """Return a writable cache directory, falling back to ``$TMPDIR``.
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
        Defaulting to ``<prefix>_cache`` next to the corpus is convenient,
        but production datasets often sit on read-only shared mounts where
        ``os.makedirs`` would raise ``EROFS``. Detect and silently relocate.
        """
        candidate = user_cache_dir or (self.indexed_dataset.path_prefix + "_cache")
        try:
            os.makedirs(candidate, exist_ok=True)
            # Probe writability — ``os.makedirs`` succeeds on directories
            # that exist read-only.
            probe = os.path.join(candidate, ".hp_cache_probe")
            with open(probe, "w", encoding="utf-8") as f:
                f.write("")
            os.remove(probe)
            return candidate
        except OSError as exc:
            fallback = os.path.join(tempfile.gettempdir(), "hp_megatron_cache")
            os.makedirs(fallback, exist_ok=True)
            logger.warning(
                "GPTDataset cache dir %s not writable (%s); using fallback %s",
                candidate, exc, fallback,
            )
            return fallback

    def __len__(self) -> int:
        return min(self.num_samples, int(self.shuffle_index.size))

    def _read_sample(self, sample_pos: int) -> np.ndarray:
        """Concatenate the ``seq_length + 1`` tokens for sample ``sample_pos``."""
        start_doc_idx, start_offset = self.sample_index[sample_pos]
        end_doc_idx, end_offset = self.sample_index[sample_pos + 1]
        parts = []
        if start_doc_idx == end_doc_idx:
            doc_tokens = self._cached_doc(int(self.document_index[start_doc_idx]))
            parts.append(doc_tokens[start_offset:end_offset + 1])
        else:
            doc_tokens = self._cached_doc(int(self.document_index[start_doc_idx]))
            parts.append(doc_tokens[start_offset:])
            for doc_id in range(start_doc_idx + 1, end_doc_idx):
                parts.append(self._cached_doc(int(self.document_index[doc_id])))
            tail_doc_tokens = self._cached_doc(int(self.document_index[end_doc_idx]))
            parts.append(tail_doc_tokens[:end_offset + 1])
        out = np.concatenate(parts) if len(parts) > 1 else parts[0]
        needed = self.seq_length + 1
        if out.size >= needed:
            return out[:needed]
        # Pad short tail (only happens for the very last sample).
        pad = np.full(needed - out.size, self.pad_token_id, dtype=out.dtype)
        return np.concatenate([out, pad])

    def _read_doc(self, doc_id: int) -> np.ndarray:
        """All tokens of ``doc_id`` as a single 1-D array (memoised)."""
        return self.indexed_dataset.get_document(doc_id)

    def __getitem__(self, idx: int) -> Dict[str, Any]:
        actual = int(self.shuffle_index[idx])
        tokens = self._read_sample(actual)
        input_ids = torch.from_numpy(tokens[: self.seq_length].astype(np.int64))
        labels = input_ids.clone()
        if self.eod_mask_loss and self.eod_token_id is not None:
            labels[input_ids == int(self.eod_token_id)] = -100
        return {"input_ids": input_ids, "labels": labels}
hyper_parallel/data/megatron/indexed_dataset.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
    The single source of truth for path-prefix normalisation — :func:`get_bin_path`,
    :func:`get_idx_path` and the dataset builder all route through here so the
    suffix rule never diverges across call sites.
    """
    return prefix[:-4] if prefix.endswith((".bin", ".idx")) else prefix


def get_bin_path(prefix: str) -> str:
    """Return ``<prefix>.bin`` (strips an already-present ``.bin``/``.idx``)."""
    return strip_suffix(prefix) + ".bin"


def get_idx_path(prefix: str) -> str:
    """Return ``<prefix>.idx``."""
    return strip_suffix(prefix) + ".idx"


def dtype_code(dtype: np.dtype) -> int:
    """Return the on-disk dtype code for ``dtype``.
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119

    Raises:
        ValueError: When ``dtype`` is not part of the Megatron dtype table.
    """
    dtype = np.dtype(dtype)
    if dtype not in _DTYPE_CODES:
        raise ValueError(
            f"Unsupported Megatron indexed-dataset dtype: {dtype}. "
            f"Allowed: {sorted(d.name for d in _DTYPE_CODES)}"
        )
    return _DTYPE_CODES[dtype]


def code_dtype(code: int) -> np.dtype:
    """Inverse of :func:`dtype_code`."""
    try:
        return _DTYPES[code]
    except KeyError as exc:
        raise ValueError(f"Unknown Megatron dtype code: {code}") from exc


class _IndexReader:
    """Memory-mapped view over the ``.idx`` companion file.
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
    is essentially free.
    """

    def __init__(self, idx_path: str, *, multimodal: bool = False) -> None:
        if not os.path.isfile(idx_path):
            raise FileNotFoundError(f"Megatron index file not found: {idx_path}")
        with open(idx_path, "rb") as f:
            magic = f.read(len(_MAGIC))
            if magic != _MAGIC:
                raise ValueError(
                    f"Not a Megatron indexed dataset (magic mismatch): "
                    f"{idx_path}. Expected {_MAGIC!r}, got {magic!r}"
                )
            version = struct.unpack("<Q", f.read(8))[0]
            if version != _VERSION:
                raise ValueError(
                    f"Unsupported indexed-dataset version: {version} "
                    f"(only v{_VERSION} is recognised)"
                )
            code = struct.unpack("<B", f.read(1))[0]
            self.dtype = code_dtype(code)
            self.sequence_count = struct.unpack("<Q", f.read(8))[0]
            self.document_count = struct.unpack("<Q", f.read(8))[0]

            # The index payload is small (4-12 bytes per sequence) — read it
            # into RAM rather than memmap. Avoids the fragility of slicing a
            # memmap with offsetted ``np.frombuffer`` while still being fast.
            self.sequence_lengths = np.frombuffer(
                f.read(self.sequence_count * 4), dtype=np.int32,
            )
            self.sequence_pointers = np.frombuffer(
                f.read(self.sequence_count * 8), dtype=np.int64,
            )
            self.document_indices = np.frombuffer(
                f.read(self.document_count * 8), dtype=np.int64,
            )
            if multimodal:
                self.sequence_modes = np.frombuffer(
                    f.read(self.sequence_count), dtype=np.int8,
                )
            else:
                self.sequence_modes = None


class IndexedDataset:
    """Read-only Megatron ``.bin``/``.idx`` dataset.
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
        byte-compatible.
    """

    def __init__(self, path_prefix: str, *, mmap: bool = True, multimodal: bool = False) -> None:
        idx_path = get_idx_path(path_prefix)
        bin_path = get_bin_path(path_prefix)
        if not os.path.isfile(bin_path):
            raise FileNotFoundError(f"Megatron .bin not found: {bin_path}")
        self._index = _IndexReader(idx_path, multimodal=multimodal)
        if mmap:
            self._bin_buffer = np.memmap(bin_path, dtype=np.uint8, mode="r", order="C")
        else:
            with open(bin_path, "rb") as f:
                data = f.read()
            self._bin_buffer = np.frombuffer(data, dtype=np.uint8)
        self.path_prefix = path_prefix

    # ------------------------------------------------------------------
    # Sequence-level access
    # ------------------------------------------------------------------
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
    # Sequence-level access
    # ------------------------------------------------------------------

    def __len__(self) -> int:
        return int(self._index.sequence_count)

    @property
    def dtype(self) -> np.dtype:
        """Token dtype (numpy)."""
        return self._index.dtype

    @property
    def sequence_lengths(self) -> np.ndarray:
        """int32[N] token-counts for each sequence."""
        return self._index.sequence_lengths

    @property
    def document_indices(self) -> np.ndarray:
        """int64[D+1] sequence-index boundaries marking documents."""
        return self._index.document_indices

    def get(self, idx: int, offset: int = 0, length: Optional[int] = None) -> np.ndarray:
        """Read a slice of sequence ``idx``.
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
            A ``np.ndarray`` of the configured dtype. The returned array is
            a view into the mmap when ``mmap=True``; callers must not write
            to it.
        """
        seq_len = int(self._index.sequence_lengths[idx])
        if length is None:
            length = seq_len - offset
        if offset < 0 or length < 0 or offset + length > seq_len:
            raise ValueError(
                f"Slice [{offset}:{offset + length}] out of bounds for "
                f"sequence {idx} of length {seq_len}"
            )
        item_size = self._index.dtype.itemsize
        byte_ptr = int(self._index.sequence_pointers[idx]) + offset * item_size
        # ``sequence_pointers`` are accumulated ``nbytes`` of items, so
        # ``byte_ptr`` is always a multiple of ``item_size`` and a typed
        # view is alignment-safe; the slice is a zero-copy view into the
        # mmap (or a plain ndarray slice in the RAM path).
        return self._bin_buffer[byte_ptr: byte_ptr + length * item_size].view(self._index.dtype)

    def __getitem__(self, idx: int) -> np.ndarray:
        return self.get(idx)

    # ------------------------------------------------------------------
    # Document-level access
    # ------------------------------------------------------------------
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287

        On disk ``document_count`` is ``D + 1`` because Megatron stores the
        full boundary array including the trailing ``N`` terminator.
        """
        return int(self._index.document_count) - 1

    def get_document(self, doc_idx: int) -> np.ndarray:
        """Concatenate all sequences belonging to ``doc_idx``."""
        if doc_idx < 0 or doc_idx >= self.num_documents:
            raise IndexError(
                f"document index {doc_idx} out of range [0, {self.num_documents})"
            )
        start = int(self._index.document_indices[doc_idx])
        end = int(self._index.document_indices[doc_idx + 1])
        parts = [self.get(i) for i in range(start, end)]
        if not parts:
            return np.array([], dtype=self._index.dtype)
        return np.concatenate(parts)


class IndexedDatasetBuilder:
    """Write a ``.bin``/``.idx`` pair sequentially.
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
        Python lists for the index metadata until :meth:`finalize`.
    """

    def __init__(self, path_prefix: str, *, dtype: np.dtype = np.int32) -> None:
        self.path_prefix = path_prefix
        self.dtype = np.dtype(dtype)
        # Make sure the dtype is supported before opening any file.
        dtype_code(self.dtype)
        self._bin_path = get_bin_path(path_prefix)
        self._idx_path = get_idx_path(path_prefix)
        os.makedirs(os.path.dirname(self._bin_path) or ".", exist_ok=True)
        self._bin_file = open(self._bin_path, "wb")  # pylint: disable=R1732
        self._sequence_lengths: List[int] = []
        self._sequence_pointers: List[int] = []
        self._document_indices: List[int] = [0]
        self._byte_offset = 0

    def add_item(self, tokens: np.ndarray) -> None:
        """Append one sequence of tokens to the dataset."""
        tokens = np.asarray(tokens, dtype=self.dtype)
        self._bin_file.write(tokens.tobytes(order="C"))
        self._sequence_lengths.append(int(tokens.size))
        self._sequence_pointers.append(self._byte_offset)
        self._byte_offset += int(tokens.nbytes)

    def end_document(self) -> None:
        """Mark the current sequence boundary as a document boundary."""
        self._document_indices.append(len(self._sequence_lengths))

    def finalize(self) -> Tuple[str, str]:
        """Close ``.bin`` and write the companion ``.idx``.
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363

        Raises:
            ValueError: When no document boundary was emitted.
        """
        self._bin_file.close()
        # ``end_document`` must have been called at least once OR the writer
        # is empty; an empty dataset still needs a 1-element boundary list.
        if self._document_indices[-1] != len(self._sequence_lengths):
            self.end_document()
        seq_lengths = np.asarray(self._sequence_lengths, dtype=np.int32)
        seq_pointers = np.asarray(self._sequence_pointers, dtype=np.int64)
        doc_indices = np.asarray(self._document_indices, dtype=np.int64)
        with open(self._idx_path, "wb") as f:
            f.write(_MAGIC)
            f.write(struct.pack("<Q", _VERSION))
            f.write(struct.pack("<B", dtype_code(self.dtype)))
            f.write(struct.pack("<Q", seq_lengths.size))
            f.write(struct.pack("<Q", doc_indices.size))
            f.write(seq_lengths.tobytes())
            f.write(seq_pointers.tobytes())
            f.write(doc_indices.tobytes())
        logger.info(
            "IndexedDatasetBuilder finalized: %d sequences, %d documents -> %s + %s",
            seq_lengths.size, doc_indices.size, self._bin_path, self._idx_path,
        )
        return self._idx_path, self._bin_path
hyper_parallel/data/preset_pt.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class PresetPtDataset(Dataset):
    """Wrap a pre-expanded list of per-sample dicts."""

    def __init__(self, samples: List[Dict[str, Any]]) -> None:
        self.samples = samples

    def __len__(self) -> int:
        return len(self.samples)

    def __getitem__(self, idx: int) -> Dict[str, Any]:
        return self.samples[idx]


_OPTIONAL_2D_FIELDS = ("attention_mask", "mm_token_type_ids")
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def _split_pixel_block(
    b: Dict[str, Any], i: int, batch_size: int, pix_key: str, grid_key: str,
) -> Dict[str, Any]:
    """Slice the ``(pix_key, grid_key)`` rows owned by sample ``i``."""
    pv = b[pix_key]
    thw = b[grid_key]
    grids_per_sample = thw.shape[0] // batch_size if thw.dim() == 2 else 0
    if grids_per_sample == 0:
        return {}
    thw_i = thw[i * grids_per_sample:(i + 1) * grids_per_sample].clone()
    pv_count = int(thw_i.prod(dim=-1).sum().item())
    offset = sum(
        int(thw[j].prod(dim=-1).sum().item())
        for j in range(i * grids_per_sample)
    )
    return {
        pix_key: pv[offset:offset + pv_count].clone(),
        grid_key: thw_i,
    }
77
78
79
80
81
82
83
84
85
86
87
88
89
90
    mRoPE ``[R, B, S]`` ids (``R`` is 3 or 4 in Transformers). Preserve the
    rotary-rank axis and slice only the batch axis so the trainer can rebuild
    the original stacked shape in its collate function.
    """
    if position_ids.dim() == 2 and position_ids.shape[0] == batch_size:
        return position_ids[sample_idx].clone()
    if position_ids.dim() == 3 and position_ids.shape[1] == batch_size:
        return position_ids[:, sample_idx].clone()
    if position_ids.dim() == 3 and position_ids.shape[0] == batch_size:
        return position_ids[sample_idx].clone()
    raise ValueError(
        "preset_pt position_ids must be [B, S], [R, B, S], or [B, R, S]; "
        f"got shape={tuple(position_ids.shape)} with batch_size={batch_size}."
    )
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
    ``attention_mask``). VL samples additionally carry ``mm_token_type_ids``
    and ``(pixel_values, image_grid_thw)`` / ``(pixel_values_videos,
    video_grid_thw)`` pairs sliced according to the per-sample grid product.
    """
    ids = b["input_ids"]
    labels = b["labels"]
    batch_size = ids.shape[0]
    out: List[Dict[str, Any]] = []
    for i in range(batch_size):
        rec: Dict[str, Any] = {
            "input_ids": ids[i].clone(),
            "labels": labels[i].clone(),
        }
        if "num_items_in_batch" in b:
            rec["num_items_in_batch"] = int((rec["labels"] != -100).sum().item())
        for k in _OPTIONAL_2D_FIELDS:
            v = b.get(k)
            if v is not None and v.dim() == 2:
                rec[k] = v[i].clone()
        position_ids = b.get("position_ids")
        if position_ids is not None:
            rec["position_ids"] = _slice_position_ids(position_ids, i, batch_size)
        if vl:
            for pix_key, grid_key in _PIXEL_PAIRS:
                if b.get(pix_key) is not None and b.get(grid_key) is not None:
                    rec.update(_split_pixel_block(b, i, batch_size, pix_key, grid_key))
        out.append(rec)
    return out


def _is_vl(batch_entry: Any) -> bool:
    """Heuristic: VL batches always carry pixel data (image or video)."""
    if isinstance(batch_entry, list):
        batch_entry = batch_entry[0] if batch_entry else None
    return isinstance(batch_entry, dict) and any(
        pix_key in batch_entry for pix_key, _ in _PIXEL_PAIRS)


@DATASET_REGISTRY.register("preset_pt")
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
    Auto-detects whether the .pt holds VL batches (pixel_values present)
    or plain LM batches by inspecting the first entry, and dispatches the
    matching per-sample expansion.
    """
    train_path = args.data.train_path
    if not train_path:
        raise ValueError("data.train_path is required when data.type='preset_pt'")
    batches = torch.load(train_path, map_location="cpu", weights_only=False)
    if not isinstance(batches, list) or not batches:
        raise ValueError(f"preset_pt expects List, got {type(batches)}")

    vl = _is_vl(batches[0])
    per_sample: List[Dict[str, Any]] = []
    for b in batches:
        if isinstance(b, list):
            for br in b:
                per_sample.extend(_expand_batch(br, vl=vl))
        else:
            per_sample.extend(_expand_batch(b, vl=vl))

    ds = PresetPtDataset(per_sample)
    max_steps = getattr(args.train, "max_steps", None)
    if max_steps:
        base.state.max_steps = int(max_steps)
    logger.info(
        "preset_pt dataset (%s): %d samples loaded from %s",
        "vl" if vl else "lm", len(per_sample), train_path,
    )
    return ds
hyper_parallel/data/registry.py
52
53
54
55
56
57
58
59
60
61
62
63
64
                is treated as a programming error (typo / duplicate import)
                rather than silently overwritten.
        """
        if not name:
            raise ValueError("Dataset registry name must be a non-empty string")

        def decorator(fn: BuilderFn) -> BuilderFn:
            if name in self._builders:
                raise ValueError(
                    f"Dataset type '{name}' is already registered; "
                    f"check for duplicate registrations / circular imports"
                )
            self._builders[name] = fn
67
68
69
70
71
72
73
74
75
76
77
78
79
        return decorator

    def get(self, name: str) -> BuilderFn:
        """Return the builder for ``name`` or raise with a helpful message."""
        try:
            return self._builders[name]
        except KeyError as exc:
            available = sorted(self._builders)
            raise ValueError(
                f"Unknown data.type '{name}'. Available: {available}. "
                f"Add a new format by registering a builder under "
                f"hyper_parallel.data."
            ) from exc
79
80
81
82
83
84
85
86
87
88
89
90
91
92
            ) from exc

    def names(self) -> List[str]:
        """Return registered names in deterministic (sorted) order."""
        return sorted(self._builders)

    def __contains__(self, name: str) -> bool:
        return name in self._builders

    def __iter__(self) -> Iterator[str]:
        return iter(self.names())


DATASET_REGISTRY = DatasetRegistry()
104
105
106
107
108
    Returns:
        A ``torch.utils.data.Dataset``-compatible object suitable for the
        trainer's distributed sampler + ``StatefulDataLoader``.
    """
    return DATASET_REGISTRY.get(name)(**kwargs)
hyper_parallel/data/vl_dummy.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
        base_seed: int,
        video_token_id: int = 151656,
        is_video: bool = False,
    ) -> None:
        self.num_samples = num_samples
        self.seq_length = seq_length
        self.grid_t = grid_t
        self.grid_h = grid_h
        self.grid_w = grid_w
        self.row_width = row_width
        self.image_tokens = image_tokens
        self.image_token_id = image_token_id
        self.base_seed = base_seed
        self.video_token_id = video_token_id
        self.is_video = is_video

    def __len__(self) -> int:
        return self.num_samples

    def __getitem__(self, idx: int):
        g = torch.Generator().manual_seed(self.base_seed + idx)
        if self.is_video:
            return self._video_item(idx, g)
        pixel_values = torch.randn(
            self.grid_t * self.grid_h * self.grid_w, self.row_width,
            generator=g, dtype=torch.float32,
        )
        input_ids = torch.full((self.seq_length,), 100, dtype=torch.long)
        input_ids[0] = 151643
        input_ids[1: 1 + self.image_tokens] = self.image_token_id
        tail = torch.arange(
            200 + idx % 17,
            200 + idx % 17 + self.seq_length - 1 - self.image_tokens,
            dtype=torch.long,
        )
        input_ids[1 + self.image_tokens:] = tail
        labels = input_ids.clone()
        mm_token_type_ids = torch.zeros(self.seq_length, dtype=torch.int32)
        mm_token_type_ids[1: 1 + self.image_tokens] = 1
        return {
            "input_ids": input_ids,
            "labels": labels,
            "attention_mask": torch.ones(self.seq_length, dtype=torch.long),
            "mm_token_type_ids": mm_token_type_ids,
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
        """Build one deterministic video sample."""
        # A video lays each of grid_t frames as its own mm==2 run (a text
        # separator between frames) so get_rope_index consumes one per-frame
        # [1,h,w] grid per run; an image is a single contiguous run.
        tokens_per_frame = self.image_tokens // self.grid_t
        pixel_values_videos = torch.randn(
            self.grid_t * self.grid_h * self.grid_w, self.row_width,
            generator=g, dtype=torch.float32,
        )
        input_ids = torch.full((self.seq_length,), 100, dtype=torch.long)
        input_ids[0] = 151643
        mm_token_type_ids = torch.zeros(self.seq_length, dtype=torch.int32)
        pos = 1
        frame_idx = 0
        while frame_idx < self.grid_t:
            input_ids[pos] = 150  # text separator → split per-frame runs
            pos += 1
            input_ids[pos: pos + tokens_per_frame] = self.video_token_id
            mm_token_type_ids[pos: pos + tokens_per_frame] = 2
            pos += tokens_per_frame
            frame_idx += 1
        tail = torch.arange(
            200 + idx % 17, 200 + idx % 17 + self.seq_length - pos,
            dtype=torch.long,
        )
        input_ids[pos:] = tail
        labels = input_ids.clone()
        return {
            "input_ids": input_ids,
            "labels": labels,
            "attention_mask": torch.ones(self.seq_length, dtype=torch.long),
            "mm_token_type_ids": mm_token_type_ids,
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
    ``spatial_merge_size`` from ``model.config_overrides.vision_config`` so
    the row width matches the Qwen3-VL vision-tower expectation; falls back
    to the published defaults when those keys are absent.
    """
    max_steps = getattr(args.train, "max_steps", 1)
    global_bs = getattr(args.train, "global_batch_size", 1)
    total_samples = max_steps * global_bs

    data_cfg = args.data
    model_cfg = args.model
    extra = getattr(model_cfg, "config_overrides", None) or {}
    vision_extra = extra.get("vision_config", {}) if isinstance(extra, dict) else {}
    patch_size = int(vision_extra.get("patch_size", 16))
    temporal_patch_size = int(vision_extra.get("temporal_patch_size", 2))
    in_channels = int(vision_extra.get("in_channels", 3))
    spatial_merge = int(vision_extra.get("spatial_merge_size", 2))
    grid_t = int(getattr(data_cfg, "vl_grid_t", 2))
    grid_h = int(getattr(data_cfg, "vl_grid_h", 2))
    grid_w = int(getattr(data_cfg, "vl_grid_w", 2))
    image_token_id = int(getattr(data_cfg, "image_token_id", 151655))
    video_token_id = int(getattr(data_cfg, "video_token_id", 151656))
    is_video = bool(getattr(data_cfg, "vl_video", False))
    image_tokens = grid_t * grid_h * grid_w // (spatial_merge ** 2)
    tokens_per_frame = grid_h * grid_w // (spatial_merge ** 2)
    row_width = in_channels * temporal_patch_size * patch_size * patch_size
    min_len = grid_t * (tokens_per_frame + 1) + 2 if is_video else image_tokens + 4
    seq_len = max(int(getattr(data_cfg, "max_seq_len", 16)), min_len)
    base_seed = int(getattr(args.train, "seed", 42))

    ds = DummyVLDataset(
        num_samples=total_samples, seq_length=seq_len,
        grid_t=grid_t, grid_h=grid_h, grid_w=grid_w, row_width=row_width,
        image_tokens=image_tokens, image_token_id=image_token_id, base_seed=base_seed,
        video_token_id=video_token_id, is_video=is_video,
172
173
174
175
176
177
178
179
180
181
        grid_t=grid_t, grid_h=grid_h, grid_w=grid_w, row_width=row_width,
        image_tokens=image_tokens, image_token_id=image_token_id, base_seed=base_seed,
        video_token_id=video_token_id, is_video=is_video,
    )
    base.state.max_steps = max_steps
    logger.info(
        "VL dummy dataset created: samples=%d seq_len=%d grid=(%d,%d,%d) image_tokens=%d",
        total_samples, seq_len, grid_t, grid_h, grid_w, image_tokens,
    )
    return ds
hyper_parallel/platform/mindspore/platform.py
1175
1176
1177
1178
1179
1180
1181
1182
1183

    @staticmethod
    def stop_gradient(tensor: Any) -> Any:
        """Return a tensor detached from MindSpore autograd."""
        return ms.ops.stop_gradient(tensor)

    @staticmethod
    def get_tensor_storage_size(tensor: Any) -> int:
        """Return serialized byte size (numel * itemsize) for a MindSpore tensor."""
hyper_parallel/platform/platform.py
262
263
264
265
266
267
268
269
270

    @staticmethod
    def stop_gradient(tensor: Any) -> Any:
        """Return ``tensor`` detached from gradient tracking."""
        raise NotImplementedError("Platform subclasses must implement stop_gradient")

    @staticmethod
    def get_tensor_storage_size(tensor: Any) -> int:
        """Return serialized byte size (numel * element size) for this framework's tensor."""
hyper_parallel/platform/torch/platform.py
898
899
900
901
902
903
904
905
906

    @staticmethod
    def stop_gradient(tensor: Any) -> Any:
        """Return a tensor detached from PyTorch autograd."""
        return tensor.detach()

    @staticmethod
    def get_tensor_storage_size(tensor: Any) -> int:
        """Return serialized byte size (numel * element size) for a PyTorch tensor."""
hyper_parallel/trainer/base.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
    # ------------------------------------------------------------------

    @property
    def _deterministic(self) -> bool:
        debug_cfg = getattr(self.args.train, 'debug', None)
        return bool(debug_cfg is not None and getattr(debug_cfg, 'deterministic', False))

    def _apply_pre_init_deterministic_env(self):
        """Pin HCCL / PYTHONHASHSEED before ``init_process_group`` boots the backend."""
        if not self._deterministic:
            return
        seed = getattr(self.args.train, 'seed', 42)
        os.environ.setdefault("ASCEND_LAUNCH_BLOCKING", "1")
        os.environ.setdefault("CUDA_LAUNCH_BLOCKING", "1")
        os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":16:8")
        os.environ.setdefault("FLASH_ATTENTION_DETERMINISTIC", "1")
        os.environ.setdefault("HCCL_DETERMINISTIC", "true")
        os.environ.setdefault("PYTHONHASHSEED", str(seed))

    def _setup(self):
        """Step 1: Initialize distributed environment, device mesh, and seed.
161
162
163
164
165
166
167
168
169

        Calls hyper's own ``init_process_group`` and ``init_device_mesh``.
        Mesh shape is derived from ``args.parallel`` (dp, tp, cp, pp, ep).
        """
        self._apply_pre_init_deterministic_env()
        backend = getattr(self.args.train, 'comm_backend', None)
        init_process_group(backend=backend)

        local_rank = getattr(self.args.train, 'local_rank', 0)
181
182
183
184
185
186
187
188
189
190
        logger.info_rank0("ParallelDims: %s", self.parallel_dims.summary())
        # Mixed precision lives in FSDP2's MixedPrecisionPolicy, so a
        # low-precision run needs a dp_shard axis (size-1 is enough) for the
        # FSDP wrap to exist — see ``build_mesh``'s force_dp_shard contract.
        mp_cfg = getattr(self.args.train, 'mixed_precision', None)
        needs_mp_wrap = bool(
            mp_cfg is not None
            and getattr(mp_cfg, 'enabled', False)
            and getattr(mp_cfg, 'param_dtype', 'bfloat16')
            not in ('float32', 'fp32')
192
193
194
195
196
197
198
199
200
201
202
203
204
        # PP stages carry the dtype policy only through a per-stage FSDP wrap,
        # which exists only for pure dp_shard sharding (no HSDP, see
        # ``_resolve_fsdp_mesh``) — reject every PP composition that would
        # silently run full-precision instead.
        if (needs_mp_wrap and self.parallel_dims.pp > 1
                and (self.parallel_dims.dp_shard == 1
                     or self.parallel_dims.dp_replicate > 1)
                and self.parallel_dims.cp == 1):
            raise ValueError(
                "mixed_precision with a low-precision param_dtype under PP "
                "needs an FSDP-wrappable data-parallel axis: the dtype policy "
                "lives on the per-stage FSDP wrap, which neither pure PP nor "
                "PP+HSDP provides. Use dp_shard>=2 with dp_replicate=1, or "
203
204
205
206
207
208
209
210
211
                "lives on the per-stage FSDP wrap, which neither pure PP nor "
                "PP+HSDP provides. Use dp_shard>=2 with dp_replicate=1, or "
                "set param_dtype=float32."
            )
        self.mesh = self.parallel_dims.build_mesh(
            platform.device_type(), force_dp_shard=needs_mp_wrap,
        )

        # Build DP group_info for trainer-level all_reduce (loss/token sync).
218
219
220
221
222
223
224
225
226
227
        )

        seed = getattr(self.args.train, 'seed', 42)
        platform.manual_seed(seed)
        random.seed(seed)
        np.random.seed(seed)
        # ``platform.manual_seed`` only covers CPU; seed the device RNG too.
        try:
            handle = platform.get_device_handle(device_type)
            if hasattr(handle, "manual_seed_all"):
230
231
232
233
234
235
236
237
238
239
                handle.manual_seed(seed)
        except Exception as exc:  # pylint: disable=W0718
            logger.warning("Device-side seed init skipped: %s", exc)

        if self._deterministic:
            debug_cfg = getattr(self.args.train, 'debug', None)
            warn_only = getattr(debug_cfg, 'deterministic_warn_only', False)
            torch.use_deterministic_algorithms(True, warn_only=warn_only)
            torch.backends.cudnn.deterministic = True
            torch.backends.cudnn.benchmark = False
237
238
239
240
241
242
243
244
245
246
247
248
249
            torch.use_deterministic_algorithms(True, warn_only=warn_only)
            torch.backends.cudnn.deterministic = True
            torch.backends.cudnn.benchmark = False
            # TF32 affects CUDA only; the attribute may be missing on older torch.
            try:
                torch.backends.cuda.matmul.allow_tf32 = False
                torch.backends.cudnn.allow_tf32 = False
            except AttributeError:
                pass
            logger.info_rank0("Deterministic algorithms enabled (warn_only=%s)", warn_only)

        logger.info_rank0(
            "Setup complete: rank=%d, world_size=%d, mesh=%s",
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341

        Subclasses can override to populate ``self.train_dataset``
        differently before this method runs (or skip it entirely).
        """
        if getattr(self, "train_dataset", None) is not None:
            return
        if getattr(self.args.data, "streaming", False):
            # ``DistributedSampler`` requires ``__len__``; an iterable path
            # would need a sampler-less dataloader. Reject loudly until that
            # path is wired so users see a clear error instead of a
            # ``TypeError: object of type ... has no len()``.
            raise NotImplementedError(
                "data.streaming=True is not yet wired. The default "
                "_build_dataloader uses DistributedSampler which requires "
                "len(dataset); subclass _build_dataset + _build_dataloader "
                "to emit an IterableDataset that self-shards via dp_rank/dp_size."
339
340
341
342
343
344
345
346
347
348
                "_build_dataloader uses DistributedSampler which requires "
                "len(dataset); subclass _build_dataset + _build_dataloader "
                "to emit an IterableDataset that self-shards via dp_rank/dp_size."
            )
        data_type = getattr(self.args.data, "type", "dummy")
        self.train_dataset = build_dataset(
            data_type,
            base=self,
            args=self.args,
            tokenizer=getattr(self, "tokenizer", None),
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
        dim, so variable-length batches additionally pad up to a multiple
        of ``cp * tp`` — the trailing pad carries label ``-100``, which the
        CE masks out, so the padding is mathematically inert.
        """
        seq_divisor = self.parallel_dims.seq_divisor

        def _default_collate(batch):
            """Simple padding collator."""
            max_len = max(item["input_ids"].size(0) for item in batch)
            if seq_divisor > 1 and max_len % seq_divisor:
                max_len += seq_divisor - max_len % seq_divisor
            input_ids_list = []
            labels_list = []
            for item in batch:
                pad_len = max_len - item["input_ids"].size(0)
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
                )
                labels_list.append(
                    torch.nn.functional.pad(item["labels"], (0, pad_len), value=-100)
                )
            out = {
                "input_ids": torch.stack(input_ids_list),
                "labels": torch.stack(labels_list),
            }
            if "num_items_in_batch" in batch[0]:
                out["num_items_in_batch"] = sum(
                    int(item["num_items_in_batch"]) for item in batch
                )
            if "attention_mask" in batch[0]:
                masks = []
                for item in batch:
                    pad_len = max_len - item["attention_mask"].size(0)
                    masks.append(torch.nn.functional.pad(item["attention_mask"], (0, pad_len), value=0))
                out["attention_mask"] = torch.stack(masks)
            if "position_ids" in batch[0]:
                positions = []
                for item in batch:
                    pos = item["position_ids"]
                    pad_len = max_len - pos.shape[-1]
                    positions.append(torch.nn.functional.pad(pos, (0, pad_len), value=0))
                if positions[0].dim() == 1:
                    out["position_ids"] = torch.stack(positions)
                else:
                    out["position_ids"] = torch.stack(positions).transpose(0, 1).contiguous()
            return out

        self.collate_fn = _default_collate

    def _build_dataloader(self):
420
421
422
423
424
425
426
427
428
429
430
        # Sampler uses DP rank/size — TP/CP/PP/EP peers share data.
        dp_size = self.parallel_dims.dp_size
        non_dp = self.parallel_dims.non_dp_size
        global_rank = platform.get_rank()
        try:
            dp_rank = self.mesh["dp"].get_local_rank()
        except (KeyError, ValueError, RuntimeError):
            dp_rank = global_rank // non_dp if non_dp > 1 else global_rank

        shuffle = getattr(self.args.data, "shuffle", True)
        sampler_seed = getattr(self.args.train, 'seed', 0)
445
446
447
448
449
450
451
452
453
454
455
456
457
458
        pin_memory = getattr(self.args.data, 'pin_memory', True)

        # Spawned-worker RNG is not bit-stable across 1c↔Nc; force num_workers=0
        # in deterministic mode.
        if self._deterministic and num_workers > 0:
            logger.warning(
                "debug.deterministic=True forces data.num_workers from %d → 0",
                num_workers,
            )
            num_workers = 0

        loader_kwargs = {
            "batch_size": micro_bs,
            "sampler": self.sampler,
463
464
465
466
467
468
469
470
471
472
473
474
        }
        # prefetch_factor is only accepted when num_workers > 0
        if num_workers > 0 and prefetch_factor is not None:
            loader_kwargs["prefetch_factor"] = prefetch_factor
        if self._deterministic:
            # Pin loader RNG to the trainer seed so shuffle order is stable.
            gen = torch.Generator()
            gen.manual_seed(int(getattr(self.args.train, 'seed', 0)))
            loader_kwargs["generator"] = gen
        self.train_dataloader = StatefulDataLoader(
            self.train_dataset, **loader_kwargs,
        )
492
493
494
495
496
497
498
499
500
501
502
        registers it via ``ModelSpec.parallelize_fn``. There is no shared
        "default" template — model-specific TP/EP/CP/AC/FSDP/Prefetch
        composition lives next to the model that needs it.
        """
        if self.parallel_dims.pp_enabled:
            self._build_pipelined_model()
            return
        if self.spec.parallelize_fn is None:
            raise ValueError(
                f"Model '{self.spec.name}' has no ``parallelize_fn`` registered "
                f"on its ModelSpec. Each model must own its parallelize "
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
        dp_replicate == tp == cp == 1``). Composing PP with FSDP/TP/CP through
        the trainer is not yet wired; those combinations raise a clear error so
        a misconfiguration fails fast instead of silently mis-reducing.
        """
        if self.spec.pipelining_fn is None:
            raise ValueError(
                f"Model '{self.spec.name}' has parallel.pp>1 but no "
                f"``pipelining_fn`` registered on its ModelSpec. Register the "
                f"model's pipeline splitter (e.g. ``pipeline_<name>_for_trainer``)."
            )
        dims = self.parallel_dims
        # PP composed with FSDP (dp_shard / dp_replicate): each stage's children
        # are wrapped as FSDP units (load-before-shard) and the 1F1B schedule
        # defers grad reduction to the final micro-batch backward — every micro
        # accumulates the unsharded grad locally, then the explicit
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
        # per-micro grad-sync defer + ``PipelineStage.execute_reduce_grad``).
        # EP shards experts within each layer (intra-stage). TP / CP shard the
        # token sequence; the pipeline carries the sequence-sharded hidden states
        # across stages (lm_head re-gathers for a full-sequence loss).
        if dims.cp > 1:
            raise NotImplementedError(
                "Trainer pipeline parallelism supports PP alone, PP+FSDP, "
                f"PP+EP+FSDP, or PP+TP+FSDP (got cp={dims.cp}). Composing PP with "
                "CP is not yet wired."
            )
        self._pp_fsdp_composed = dims.dp_shard > 1 or dims.dp_replicate > 1
        # The PP loss/grad is normalized to the global token mean. This is
        # exactly equivalent to ``rank_average`` when every row has the same
        # number of valid labels; the runtime validates that case before the
        # schedule runs.
        agg = getattr(self.args.train.optimizer, 'loss_aggregation', 'token_weighted')
        if agg not in ('token_weighted', 'rank_average'):
            raise NotImplementedError(
                f"Trainer PP supports loss_aggregation='token_weighted' or "
                f"'rank_average' with uniform valid-token rows only (got {agg!r})."
            )
        # The schedule splits each per-step batch into ``pp_micro_batch_num``
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
        # *effective* batch (``grad_accum * micro_batch_size``), which the
        # dataloader floors from ``global_batch_size`` — validate that, not the
        # configured ``global_batch_size``, so a partial group can't silently
        # drop every step.
        micro_num = int(getattr(self.args.train.accelerator, 'pp_micro_batch_num', 1))
        if micro_num < 1:
            raise ValueError(f"pp_micro_batch_num ({micro_num}) must be >= 1.")
        global_bs = getattr(self.args.train, 'global_batch_size', None)
        micro_bs = int(getattr(self.args.train, 'micro_batch_size', 1))
        if global_bs is not None:
            grad_accum = max(int(global_bs) // (micro_bs * dims.dp_size), 1)
            effective_bs = grad_accum * micro_bs
            if effective_bs % micro_num != 0:
                raise ValueError(
                    f"effective PP batch ({effective_bs} = grad_accum*"
                    f"micro_batch_size, floored from global_batch_size={global_bs}) "
                    f"must be divisible by pp_micro_batch_num ({micro_num}); "
                    f"adjust global_batch_size / micro_batch_size / pp_micro_batch_num."
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
        # The PP path bypasses ``parallelize_fn`` (and thus ``_apply_ac``) and
        # replaces ``self.model`` with a stage fragment, so activation
        # checkpointing and HF-weight export are not yet wired — reject them
        # explicitly instead of silently skipping AC or crashing the exporter.
        ac_mode = getattr(self.args.train.gradient_checkpointing, "activation_checkpoint", "off")
        if ac_mode not in ("off", "none", None, False, ""):
            raise NotImplementedError(
                f"activation_checkpoint={ac_mode!r} is not yet wired for the "
                f"trainer PP path; set gradient_checkpointing.activation_checkpoint "
                f"to 'none' for pp>1."
            )
        ckpt_cfg = getattr(self.args.train, "checkpoint", None)
        if ckpt_cfg is not None and getattr(ckpt_cfg, "save_hf_weights", False):
            raise NotImplementedError(
                "checkpoint.save_hf_weights is not yet supported under the "
                "trainer PP path (each rank holds only a stage fragment); set "
                "save_hf_weights=false for pp>1."
            )
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
            )
        # Capture the tie flag while ``self.model`` is still the full model — the
        # PP grad-clip dedups the tied embed / lm_head, which otherwise lives on
        # two stages (stage 0's ``embed_tokens`` + the last stage's ``lm_head``).
        self._pp_tie_embeddings = bool(
            getattr(self.model.config, "tie_word_embeddings", False)
        )
        init_device = getattr(self.args.train, 'init_device', 'meta')
        if self._pp_fsdp_composed:
            if init_device != "meta":
                raise NotImplementedError(
                    "Trainer PP+FSDP currently requires init_device='meta' "
                    f"(got {init_device!r}): each stage's FSDP units are sharded "
                    "on the meta device, then materialized + weight-loaded as "
                    "shards — the same meta path as non-PP FSDP."
617
618
619
620
621
622
623
624
625
626
627
628
629
            # those exact param objects, so its shards receive the weights too).
            # Doing it the other way round (materialize full → ``fully_shard`` a
            # real param) leaves the loaded full tensor in place and trips FSDP's
            # sharded-size check at the first forward.
            self.pp_schedule, stages = self.spec.pipelining_fn(
                self.model, self.mesh, self.args,
            )
            self._pp_stage_modules = [stage.submodule for stage in stages]
            self._post_parallelize()
            # The stage was built while the model was still on meta (so
            # ``fully_shard`` could create meta shards), which left
            # ``stage.device`` on meta. ``_post_parallelize`` materialized the
            # params to the real device; point the stage there too so its P2P
628
629
630
631
632
633
634
635
636
637
638
639
640
641
            # ``stage.device`` on meta. ``_post_parallelize`` materialized the
            # params to the real device; point the stage there too so its P2P
            # activation buffers — allocated lazily on ``stage.device`` — land
            # on the compute device instead of meta.
            for stage in stages:
                stage.device = self.device
                # The stage's init-time shared-parameter broadcast was skipped on
                # meta; now that the shards are materialized + weight-loaded, sync
                # the tied embed / lm_head ends so both stages start identical.
                stage._sync_shared_parameters()  # pylint: disable=protected-access
        else:
            # PP alone: materialize + load the full model, then split (no FSDP
            # wrap). The full model must be on the trainer device before the
            # split so a CPU ``init_device`` doesn't leave stages on CPU while
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
            # PP alone: materialize + load the full model, then split (no FSDP
            # wrap). The full model must be on the trainer device before the
            # split so a CPU ``init_device`` doesn't leave stages on CPU while
            # ``_pp_train_step`` moves batches to ``self.device``.
            self._post_parallelize()
            self.model = self.model.to(self.device)
            self.pp_schedule, stages = self.spec.pipelining_fn(
                self.model, self.mesh, self.args,
            )
            self._pp_stage_modules = [stage.submodule for stage in stages]
        pp_mesh = self.mesh["pp"]
        pp_rank = pp_mesh.get_local_rank()
        self.pp_enabled = True
        self.pp_micro_batch_num = micro_num
        self.pp_has_first_stage = pp_rank == 0
        self.pp_has_last_stage = pp_rank == pp_mesh.size() - 1
        # Pipeline group for broadcasting the last stage's loss to every rank.
        self._pp_group_info = GroupInfo(
            group_name="trainer_pp", group=pp_mesh.get_group(),
            rank_size=pp_mesh.size(),
        )
        # First stage's global rank — the broadcast source for single-reader
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
            rank_size=pp_mesh.size(),
        )
        # First stage's global rank — the broadcast source for single-reader
        # data loading in ``_pp_train_step`` (constant, so resolve it once).
        self._pp_src_rank = platform.get_global_rank(pp_mesh.get_group(), 0)
        # Re-point ``self.model`` at this rank's stage(s) so the optimizer and
        # gradient clipping operate on the stage parameters only. Under VPP a
        # rank owns several non-contiguous chunks; expose all their submodules
        # (a ModuleList) so every chunk's params are optimized / clipped.
        if len(stages) == 1:
            self.model = stages[0].submodule
        else:
            self.model = torch.nn.ModuleList([s.submodule for s in stages])
        logger.info_rank0(
            "Pipeline build: pp_size=%d, this rank is stage %d (first=%s, last=%s)",
            pp_mesh.size(), pp_rank, self.pp_has_first_stage, self.pp_has_last_stage,
        )
708
709
710
711
712
713
714
715
716
        # Mixed-precision storage policy: respect the configured param_dtype
        # for both trainable and frozen params so optimizer state follows the
        # same precision contract the forward advertises.
        self._maybe_downcast_frozen_params()
        self._maybe_cast_trainable_params()
        self.model.train()

    def _maybe_downcast_frozen_params(self) -> None:
        """Maybe downcast frozen params (internal)."""
764
765
766
767
768
769
770
771
772
        mp_cfg = getattr(self.args.train, 'mixed_precision', None)
        if mp_cfg is None or not getattr(mp_cfg, 'enabled', False):
            return

        dtype_map = {
            'bfloat16': torch.bfloat16,
            'bf16': torch.bfloat16,
            'float16': torch.float16,
            'fp16': torch.float16,
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
            'fp16': torch.float16,
            'float32': torch.float32,
            'fp32': torch.float32,
        }
        target_dtype = dtype_map.get(getattr(mp_cfg, 'param_dtype', 'bfloat16'))
        if target_dtype is None:
            return
        target_reduce_dtype = dtype_map.get(getattr(mp_cfg, 'reduce_dtype', None))

        def _get_param_local_tensor(param: platform.Parameter) -> platform.Tensor:
            data = param.data
            if isinstance(data, DTensor):
                return data.to_local()
            return data

        def _set_param_local_tensor(param: platform.Parameter, local: platform.Tensor) -> None:
            data = param.data
            if isinstance(data, DTensor):
                param.data = DTensor.from_local(
                    local,
                    device_mesh=data.device_mesh,
                    placements=data.placements,
                )
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
                    device_mesh=data.device_mesh,
                    placements=data.placements,
                )
            else:
                param.data = local

        def _cast_param_data(param: platform.Parameter) -> bool:
            if not param.requires_grad:
                return False
            local = _get_param_local_tensor(param)
            if local.dtype == target_dtype:
                return False
            new_local = local.to(target_dtype)
            _set_param_local_tensor(param, new_local)
            return True

        n_cast = 0
        seen_param_ids = set()
        for _, param in self.model.named_parameters():
            seen_param_ids.add(id(param))
            if _cast_param_data(param):
                n_cast += 1
        def _refresh_hsdp_dtype(hsdp_param) -> None:
            hsdp_param.orig_dtype = target_dtype
            hsdp_param.param_dtype = None
            hsdp_param.reduce_dtype = (
                None if target_reduce_dtype == target_dtype else target_reduce_dtype
            )
            hsdp_param.all_gather_outputs = []
            param = getattr(hsdp_param, 'sharded_param', None)
            if param is not None:
                local = _get_param_local_tensor(param)
                if not local.is_contiguous():
                    local = local.contiguous()
                    _set_param_local_tensor(param, local)
                # HSDP all-gather reads this cached flat view, so it must be
                # rebound after any post-load Parameter dtype cast.
                hsdp_param._sharded_param_data = local.view(-1)  # pylint: disable=protected-access
            if hasattr(hsdp_param, "_unsharded_param"):
                delattr(hsdp_param, "_unsharded_param")

        def _refresh_hsdp_state_dtype(state) -> None:
            reduce_dtype = None if target_reduce_dtype == target_dtype else target_reduce_dtype
            if hasattr(state, '_orig_dtype'):
                state._orig_dtype = target_dtype  # pylint: disable=protected-access
            if hasattr(state, '_reduce_dtype'):
                state._reduce_dtype = reduce_dtype  # pylint: disable=protected-access
            param_group = getattr(state, 'param_group', None)
            if param_group is None:
                return
            param_group._orig_dtype = target_dtype  # pylint: disable=protected-access
            param_group._reduce_dtype = reduce_dtype  # pylint: disable=protected-access
            param_group._flat_param_buffer = None  # pylint: disable=protected-access
            param_group._flat_cast_buffer = None  # pylint: disable=protected-access
            param_group.ag_output = None
            param_group.metadata_cache = None
            param_group._result = None  # pylint: disable=protected-access

        for state in self._iter_hsdp_states():
            buckets = (
                getattr(state, 'replicate_params', []) or [],
                getattr(state, 'hsdp_params', []) or [],
            )
            for bucket in buckets:
                for hsdp_param in bucket:
                    param = getattr(hsdp_param, 'sharded_param', None)
                    if param is None:
                        continue
                    if id(param) not in seen_param_ids and _cast_param_data(param):
                        n_cast += 1
                    seen_param_ids.add(id(param))
                    _refresh_hsdp_dtype(hsdp_param)
            _refresh_hsdp_state_dtype(state)
        logger.info_rank0(
            "Post-load: cast %d trainable params to %s", n_cast, target_dtype,
        )
962
963
964
965
966
967
968
969
970
        ``.to(weight.dtype)`` cast points handle the fp32 residual stream.
        """
        mp_cfg = getattr(self.args.train, 'mixed_precision', None)
        self.model_fwd_context = nullcontext()
        self.model_bwd_context = nullcontext()
        self.grad_scaler = None
        if mp_cfg and getattr(mp_cfg, 'enabled', False):
            logger.info_rank0(
                "Mixed precision via FSDP2 mp_policy: param=%s reduce=%s on %s",
1167
1168
1169
1170
1171
1172
1173
1174
1175
        if isinstance(value, list):
            return [self._move_value_to_device(v) for v in value]
        if isinstance(value, tuple):
            return tuple(self._move_value_to_device(v) for v in value)
        return value

    def _prepare_forward_batch(self, micro_batch):
        """Move a micro-batch to device and extract CP-shifted labels."""
        micro_batch = {
1178
1179
1180
1181
1182
1183
1184
1185
1186
        }
        labels_are_shifted = bool(micro_batch.pop("_hp_labels_are_shifted", False))
        shifted_labels = micro_batch.pop("labels", None) if labels_are_shifted else None
        if labels_are_shifted and shifted_labels is None:
            raise ValueError("CP-shifted loss marker is set but labels are missing.")
        return micro_batch, labels_are_shifted, shifted_labels

    def _compute_micro_loss(
        self,
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
        if not labels_are_shifted:
            loss = outputs["loss"] if isinstance(outputs, dict) else outputs.loss
            return loss, loss.detach() * max(micro_batch_tokens, 1)

        logits = outputs["logits"] if isinstance(outputs, dict) else outputs.logits
        target_device = logits.device if hasattr(logits, "device") else self.device
        shifted_labels = shifted_labels.to(target_device, non_blocking=True)
        loss_sum = torch.nn.functional.cross_entropy(
            logits.float().view(-1, logits.size(-1)),
            shifted_labels.contiguous().view(-1),
            ignore_index=-100,
            reduction="sum",
1202
1203
1204
1205
1206
1207
1208
1209
1210
            shifted_labels.contiguous().view(-1),
            ignore_index=-100,
            reduction="sum",
        )
        return loss_sum / max(micro_batch_tokens, 1), loss_sum

    def _scale_loss_for_backward(
        self,
        loss,
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
                "hp_rank_average_loss_scale_size",
                1,
            )
            if rank_average_loss_scale_size != 1:
                scaled_loss = scaled_loss / rank_average_loss_scale_size
            return scaled_loss

        loss_scale_size = getattr(self.model, "hp_token_loss_scale_size", dp_size)
        if labels_are_shifted:
            scaled_loss = loss_sum / max(global_tokens, 1) * loss_scale_size
        else:
            scaled_loss = mean_global_loss(
                loss, micro_batch_tokens, global_tokens, loss_scale_size,
            )
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
            "hp_loss_tp_scale_size",
            max(1, getattr(self.parallel_dims, "tp", 1)),
        )
        if tp_loss_scale_size != 1:
            scaled_loss = scaled_loss / tp_loss_scale_size
        ep_loss_scale_size = getattr(self.model, "hp_loss_ep_scale_size", 1)
        if ep_loss_scale_size != 1:
            scaled_loss = scaled_loss / ep_loss_scale_size
        return scaled_loss

    def forward_backward_step(
        self,
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
        """
        cp_size = int(getattr(self.parallel_dims, "cp", 1) or 1)
        if cp_size <= 1:
            return micro_batches
        cp_rank = self.mesh["cp"].get_local_rank()
        sharded = []
        for micro_batch in micro_batches:
            input_ids = micro_batch["input_ids"]
            seq_len = input_ids.shape[1]
            if seq_len % cp_size != 0:
                raise ValueError(
                    f"sequence length ({seq_len}) must be divisible by cp ({cp_size})."
                )
            shard = seq_len // cp_size
            start = cp_rank * shard
            seq_slice = slice(start, start + shard)
            local = dict(micro_batch)
            local["input_ids"] = input_ids[:, seq_slice].contiguous()
            position_ids = micro_batch.get("position_ids")
            if position_ids is not None:
                if position_ids.dim() == 2:
                    local["position_ids"] = position_ids[:, seq_slice].contiguous()
                else:
                    pos_slice = [slice(None)] * position_ids.dim()
                    pos_slice[-1] = seq_slice
                    local["position_ids"] = position_ids[tuple(pos_slice)].contiguous()
            else:
                has_multimodal_positions = any(
                    micro_batch.get(name) is not None
                    for name in (
                        "pixel_values", "image_grid_thw", "pixel_values_videos",
                        "video_grid_thw", "mm_token_type_ids",
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
                        "pixel_values", "image_grid_thw", "pixel_values_videos",
                        "video_grid_thw", "mm_token_type_ids",
                    )
                )
                if not has_multimodal_positions:
                    local["position_ids"] = torch.arange(
                        start, start + shard, device=input_ids.device, dtype=torch.long,
                    ).view(1, -1).expand(input_ids.shape[0], -1)
            labels = micro_batch.get("labels")
            if labels is not None:
                shifted = torch.nn.functional.pad(labels, (0, 1), value=-100)[..., 1:]
                local["labels"] = shifted[:, seq_slice].contiguous()
                local["_hp_labels_are_shifted"] = True
            attn = micro_batch.get("attention_mask")
            if attn is not None and hasattr(attn, "dim") and attn.dim() == 2:
                local["attention_mask"] = attn[:, seq_slice].contiguous()
            sharded.append(local)
        return sharded

    def _collect_global_tokens(self, token_counts):
        """Count valid loss tokens and all-reduce across the data-parallel group."""
        local_tokens = sum(token_counts) or 1
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
        """Count valid loss tokens and all-reduce across the data-parallel group."""
        local_tokens = sum(token_counts) or 1
        global_tokens = local_tokens
        if platform.get_world_size() > 1 and self._dp_group_info.group is not None:
            token_tensor = platform.full((1,), local_tokens).to(self.device)
            platform.all_reduce(token_tensor, self._dp_group_info)
            global_tokens = max(int(token_tensor.item()), 1)
        self._last_global_tokens = global_tokens
        return local_tokens, global_tokens

    def _run_micro_batches(self, micro_batches, token_counts, global_tokens):
1411
1412
1413
1414
1415
1416
1417
1418
1419
    def _run_post_fsdp_grad_reduce(self) -> None:
        """Run an optional model-provided reducer after FSDP gradients drain."""
        post_fsdp_grad_reduce = getattr(self.model, "hp_post_fsdp_grad_reduce", None)
        if post_fsdp_grad_reduce is not None:
            post_fsdp_grad_reduce()

    def _non_pp_clip_grad_norm(self, max_grad_norm: float):
        """Clip non-pipeline gradients using the configured clipping function."""
        clip_fn = self.spec.clip_grad_fn or clip_grad_norm_
1428
1429
1430
1431
1432
1433
1434
1435
1436

        with SkipDTensorDispatch():
            self.optimizer.step()
        if self.lr_scheduler is not None:
            self.lr_scheduler.step()
        self.optimizer.zero_grad()
        return grad_norm_value

    def _aggregate_non_pp_loss(
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
        agg = getattr(self.args.train.optimizer, "loss_aggregation", "token_weighted")
        cp_size = int(getattr(self.parallel_dims, "cp", 1) or 1)
        if agg == "token_weighted" or (agg == "rank_average" and cp_size > 1):
            if platform.get_world_size() > 1 and self._dp_group_info.group is not None:
                loss_tensor = platform.full((1,), total_loss_sum).to(self.device)
                platform.all_reduce(loss_tensor, self._dp_group_info)
                return loss_tensor.item() / max(global_tokens, 1)
            return total_loss_sum / max(total_tokens_local, 1)

        local_mean = total_loss_arith_sum / max(num_micro, 1)
        dp_size = self._dp_group_info.rank_size
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
        local_mean = total_loss_arith_sum / max(num_micro, 1)
        dp_size = self._dp_group_info.rank_size
        if dp_size <= 1:
            return local_mean
        loss_tensor = platform.full((1,), local_mean).to(self.device)
        platform.all_reduce(loss_tensor, self._dp_group_info)
        return loss_tensor.item() / dp_size

    def _average_model_parallel_metric(self, avg_loss: float) -> float:
        """Average replicated loss metrics over model-parallel EP when needed."""
        metric_dims = ("ep",)
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
        if (
            int(getattr(self.parallel_dims, "tp", 1) or 1) > 1
            and int(getattr(self.parallel_dims, "ep", 1) or 1) > 1
        ):
            metric_dims = ()
        for mp_dim in metric_dims:
            mp_size = int(getattr(self.parallel_dims, mp_dim, 1) or 1)
            if mp_size <= 1:
                continue
            try:
                mp_group = self.mesh.get_group(mp_dim)
            except (KeyError, ValueError):
                continue
            metric = platform.full((1,), avg_loss).to(self.device)
            mp_group_info = GroupInfo(
                group_name=f"trainer_{mp_dim}_metric",
                group=mp_group,
                rank_size=mp_size,
            )
            platform.all_reduce(metric, mp_group_info)
            avg_loss = metric.item() / mp_size
        return avg_loss

    def train_step(self, data_iterator):
        """Execute one training step with gradient accumulation.
1496
1497
1498
1499
1500
1501
1502
1503
1504
        Args:
            data_iterator: Iterator yielding lists of micro-batch dicts.
        """
        if self.pp_enabled:
            return self._pp_train_step(data_iterator)
        micro_batches = next(data_iterator)
        prepare_batch_fn = getattr(self.spec, "prepare_batch_fn", None)
        if prepare_batch_fn is not None:
            micro_batches = [
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
        fixed ``max_seq_len`` so the grad-accum group is uniform, or size the
        batch so ``grad_accum == 1``. Non-tensor values are taken from the first
        micro-batch.
        """
        if len(micro_batches) == 1:
            return dict(micro_batches[0])
        merged = {}
        for key in micro_batches[0].keys():
            values = [mb[key] for mb in micro_batches]
            first = values[0]
            if not hasattr(first, "dim"):
                merged[key] = first
                continue
            if any(value.shape[1:] != first.shape[1:] for value in values):
                raise NotImplementedError(
                    f"PP gradient accumulation requires uniform-shape "
                    f"micro-batches; '{key}' varies across the group (shapes "
                    f"{[tuple(value.shape) for value in values]}). Pad to a fixed "
                    f"max_seq_len, or size the batch so grad_accum == 1."
1565
1566
1567
1568
1569
1570
1571
1572
1573
                    f"micro-batches; '{key}' varies across the group (shapes "
                    f"{[tuple(value.shape) for value in values]}). Pad to a fixed "
                    f"max_seq_len, or size the batch so grad_accum == 1."
                )
            merged[key] = torch.cat(values, dim=0)
        return merged

    def _pp_clip_grad_norm(self, max_grad_norm: float):
        """Clip gradients by the **global** norm across all pipeline stages.
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708

        Returns:
            The global gradient norm (a scalar tensor) for logging.
        """
        params = [p for p in self.model.parameters() if p.grad is not None]
        skip = None
        if self._pp_tie_embeddings and self.pp_has_last_stage:
            # The last global stage's submodule owns the tied ``lm_head``. Under
            # VPP ``self.model`` is a ModuleList of this rank's chunks, only one
            # of which (the last stage) carries ``lm_head`` — find it there.
            head_owner = self.model
            if isinstance(head_owner, torch.nn.ModuleList):
                head_owner = next(
                    (s for s in head_owner if hasattr(s, "lm_head")), None)
            if head_owner is not None and hasattr(head_owner, "lm_head"):
                skip = head_owner.lm_head.weight
        local_sq = torch.zeros((), device=self.device, dtype=torch.float32)
        for param in params:
            if param is skip:
                continue
            grad = param.grad.detach()
            # Under PP+FSDP the grad is a sharded DTensor; reduce on the local
            # shard so the cross-stage all-reduce stays a plain-tensor collective.
            if hasattr(grad, "to_local"):
                grad = grad.to_local()
            local_sq = local_sq + grad.float().pow(2).sum()
        platform.all_reduce(local_sq, self._pp_group_info)
        # Under PP+FSDP the grads are dp-sharded, so also sum the per-dp-shard
        # squared norms across the dp group to get the true global grad norm.
        if getattr(self, "_pp_fsdp_composed", False):
            platform.all_reduce(local_sq, self._dp_group_info)
        total_norm = local_sq.sqrt()
        clip_coef = (max_grad_norm / (total_norm + 1e-6)).clamp(max=1.0)
        for param in params:
            param.grad.mul_(clip_coef.to(param.grad.dtype))
        return total_norm

    def _pp_load_first_stage_batch(self, data_iterator):
        """Load and prepare the global PP batch on the first stage only."""
        batch = None
        targets = None
        stop = 0
        if not self.pp_has_first_stage:
            return batch, targets, stop
        try:
            micro_batches = next(data_iterator)
            batch = self._pp_concat_micro_batches(micro_batches)
            batch = {
                key: (value.to(self.device, non_blocking=True) if hasattr(value, "to") else value)
                for key, value in batch.items()
            }
            if batch["input_ids"].shape[0] % self.pp_micro_batch_num != 0:
                stop = 1
            else:
                labels = batch["labels"]
                targets = torch.nn.functional.pad(labels, (0, 1), value=-100)[..., 1:].to(torch.int64)
        except StopIteration:
            stop = 1
        return batch, targets, stop

    def _pp_broadcast_control(self, batch, targets, stop: int):
        """Broadcast stop/shape metadata across the pipeline group."""
        ctrl = platform.full((4,), 0, dtype=torch.int64).to(self.device)
        if stop:
            ctrl[0] = 1
        elif self.pp_has_first_stage:
            ctrl[1] = int(targets.shape[0])
            ctrl[2] = int(targets.shape[1])
            ctrl[3] = 1 if batch.get("attention_mask") is not None else 0
        platform.broadcast(ctrl, self._pp_src_rank, self._pp_group_info.group)
        return ctrl.tolist()

    def _pp_broadcast_2d_int64(self, src_tensor, rows: int, seq: int):
        """Broadcast one 2-D int64 tensor from the first pipeline stage."""
        tensor = (
            src_tensor.to(torch.int64).contiguous()
            if self.pp_has_first_stage
            else platform.full((rows, seq), 0, dtype=torch.int64).to(self.device)
        )
        platform.broadcast(tensor, self._pp_src_rank, self._pp_group_info.group)
        return tensor

    def _pp_prepare_broadcast_inputs(self, batch, targets, stop: int):
        """Broadcast targets and optional all-stage masks for one PP step."""
        stop, rows, seq, has_attn = self._pp_broadcast_control(batch, targets, stop)
        if stop:
            raise StopIteration

        targets = self._pp_broadcast_2d_int64(targets, rows, seq)
        attention_mask = None
        if has_attn:
            source_mask = batch["attention_mask"] if self.pp_has_first_stage else None
            attention_mask = self._pp_broadcast_2d_int64(source_mask, rows, seq)
        return targets, attention_mask, has_attn

    def _pp_count_valid_tokens(self, targets) -> int:
        """Count valid shifted targets and sum across DP for PP+FSDP."""
        n_valid = max(int((targets != -100).sum().item()), 1)
        if getattr(self, "_pp_fsdp_composed", False):
            token_tensor = platform.full((1,), n_valid).to(self.device)
            platform.all_reduce(token_tensor, self._dp_group_info)
            n_valid = max(int(token_tensor.item()), 1)
        self._last_global_tokens = n_valid
        return n_valid

    def _pp_validate_rank_average_targets(self, targets) -> None:
        """Validate the PP token-mean path also represents rank-average loss."""
        agg = getattr(self.args.train.optimizer, "loss_aggregation", "token_weighted")
        if agg != "rank_average":
            return
        row_tokens = (targets != -100).sum(dim=1)
        if row_tokens.numel() <= 1:
            return
        if int(row_tokens.min().item()) == int(row_tokens.max().item()):
            return
        raise NotImplementedError(
            "Trainer PP with loss_aggregation='rank_average' requires uniform "
            "valid-token counts per row so the fused token-mean loss matches "
            "the single-card rank-average gradient."
        )
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
        )

    def _pp_set_stage_loss_scale(self, n_valid: int) -> None:
        """Seed pipeline stages with the token-mean loss scale."""
        dp_size = max(int(self.parallel_dims.dp_size), 1)
        pp_loss_scale = dp_size / n_valid
        for stage in getattr(self.pp_schedule, "stages", ()):
            stage.loss_scale = pp_loss_scale

    def _pp_run_schedule(self, batch, targets, attention_mask, has_attn):
        """Run the configured PP schedule with the broadcast inputs."""
        run_kwargs = {"targets": targets}
        kwargs_batch_dim = getattr(self.pp_schedule, "_kwargs_batch_dim", {}) or {}
        if self.pp_has_first_stage:
            for key in kwargs_batch_dim:
                if key != "targets" and key in batch:
                    run_kwargs[key] = batch[key]
            return self.pp_schedule.run(batch["input_ids"], **run_kwargs)
        if has_attn and "attention_mask" in kwargs_batch_dim:
            run_kwargs["attention_mask"] = attention_mask
        return self.pp_schedule.run(**run_kwargs)

    def _pp_post_schedule_grad_reduce(self) -> None:
        """Run optional post-FSDP reducers on local pipeline stage modules."""
        stage_modules = list(self.model) if isinstance(self.model, torch.nn.ModuleList) else [self.model]
        for stage_module in stage_modules:
            stage_tp_reduce = getattr(stage_module, "hp_post_fsdp_grad_reduce", None)
            if stage_tp_reduce is not None:
                stage_tp_reduce()

    def _pp_average_plain_dp_grads(self) -> None:
        """Average plain replicated grads for PP+DP without per-stage FSDP shards."""
        if not getattr(self, "_pp_fsdp_composed", False):
            return
        dp_size = max(int(self.parallel_dims.dp_size), 1)
        first_param = next(self.model.parameters(), None)
        params_fsdp_sharded = isinstance(first_param, DTensor)
        if dp_size <= 1 or params_fsdp_sharded:
            return
        for param in self.model.parameters():
            if param.grad is not None:
                platform.all_reduce(param.grad, self._dp_group_info)
                param.grad.div_(dp_size)

    def _pp_reduce_reported_loss(self, outputs, n_valid: int) -> float:
        """Reduce last-stage sum-CE into a reported token-mean PP loss."""
        local_sum_ce = 0.0
        if self.pp_has_last_stage:
            local_sum_ce = sum(out.detach().float() for out in outputs).item()
        sum_ce_t = platform.full((1,), local_sum_ce).to(self.device)
        if getattr(self, "_pp_fsdp_composed", False):
            platform.all_reduce(sum_ce_t, self._dp_group_info)
        loss_t = sum_ce_t / n_valid
        platform.all_reduce(loss_t, self._pp_group_info)
        return loss_t.item()

    def _pp_train_step(self, data_iterator):
        """Pipeline-parallel training step (``pp > 1``).
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
        Gradient clipping uses the **global** cross-stage norm
        (:meth:`_pp_clip_grad_norm`) so every stage scales by the same
        coefficient — required so the tied embed / lm_head copies stay in sync.
        """
        batch, targets, stop = self._pp_load_first_stage_batch(data_iterator)
        targets, attention_mask, has_attn = self._pp_prepare_broadcast_inputs(batch, targets, stop)
        self.state.global_step += 1
        self._pp_validate_rank_average_targets(targets)
        n_valid = self._pp_count_valid_tokens(targets)
        self._pp_set_stage_loss_scale(n_valid)
        outputs = self._pp_run_schedule(batch, targets, attention_mask, has_attn)
        self._pp_post_schedule_grad_reduce()
        self._pp_average_plain_dp_grads()
        grad_norm_value = self._optimizer_step_after_backward(self._pp_clip_grad_norm)
        return {"loss": self._pp_reduce_reported_loss(outputs, n_valid), "grad_norm": grad_norm_value}

    def train(self):
        """Main training loop: epoch → step → micro-batch.
1817
1818
1819
1820
1821
1822
1823
1824
1825
        for epoch in range(num_epochs):
            if self.state.global_step >= self.state.max_steps:
                break
            self.state.epoch = epoch
            if hasattr(self, 'sampler'):
                self.sampler.set_epoch(epoch)
            self.on_epoch_begin()

            # Build micro-batch iterator from the stateful dataloader.
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
                continue
        # No data-parallel axis: pure TP still needs the 1-D group because its
        # SequenceParallel ranks hold different token shards. Pure EP peers see
        # the same tokens and must not be folded into the token/loss denominator.
        if self.mesh.mesh_dim_names == ("ep",):
            return None
        # Other 1-D meshes (pure TP; pure CP normally has a ``loss`` alias)
        # return their own group. Multi-dim meshes with no DP/loss axis return
        # ``None``.
        try:
            return self.mesh.get_group()
        except (ValueError, RuntimeError):
            return None

    def _build_fsdp_kwargs(self) -> dict:
        """Build kwargs for ``fully_shard`` calls (dense parameters).
1990
1991
1992
1993
1994
1995
1996
1997
1998
        # PP: the tied embed / lm_head live on different stages, kept consistent
        # by the pipeline ``SharedParameterInfo`` (init broadcast + grad
        # all-reduce); a model-level tie would alias them into one object and
        # orphan the captured shared parameter (its grad would stay ``None``).
        if hasattr(self.model, "tie_weights") and int(getattr(self.parallel_dims, "pp", 1)) <= 1:
            self.model.tie_weights()
        # ``to_empty`` strips DTensor; ``lazy_init`` re-wraps shards before
        # ``_load_weights`` / optimizer step see the params (the forward
        # pre-hook does the same later, but the loader needs DTensor first).
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
        )

    def _iter_hsdp_states(self):
        """Yield the HSDP state attached to every HSDP-wrapped submodule."""
        seen = set()
        roots = [self.model, *getattr(self, "_pp_stage_modules", [])]
        for root in roots:
            if root is None:
                continue
            for module in root.modules():
                if not isinstance(module, HSDPModule):
                    continue
                scheduler = getattr(module, 'hsdp_scheduler', None)
                state = getattr(scheduler, 'hsdp_state', None) if scheduler else None
                if state is None or id(state) in seen:
                    continue
                seen.add(id(state))
                yield state

    def _materialize_replicate_params(self, device_type: str) -> None:
        """Materialize meta ``_local_tensor`` storage that ``to_empty`` cannot reach.
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
        ``_validate_no_meta_params`` in ``lazy_init``. The two buckets are
        disjoint by construction (see ``state.py`` ``_init_hsdp_params``).
        """
        for state in self._iter_hsdp_states():
            buckets = (
                getattr(state, 'replicate_params', []) or [],
                getattr(state, 'hsdp_params', []) or [],
            )
            for bucket in buckets:
                for hsdp_param in bucket:
                    local = getattr(hsdp_param.sharded_param, "_local_tensor", None)
                    if local is not None and local.is_meta:
                        new_local = torch.empty_like(local, device=device_type)
                        hsdp_param.sharded_param._local_tensor = new_local  # pylint: disable=W0212

    def _init_local_shards(self) -> int:
        """Init local shard of every param (kaiming for >=2D, zero else); zero buffers."""
        param_count = 0
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142

        Args:
            valid_sd: Fully-qualified name → plain tensor, already shape-checked.
        """
        if isinstance(self.model, HSDPModule):
            self.model.load_state_dict(valid_sd, strict=False)
            return
        if not any(isinstance(p, DTensor) for _, p in self.model.named_parameters()):
            self.model.load_state_dict(valid_sd, strict=False)
            return
        targets: Dict[str, Any] = dict(self.model.named_parameters())
        targets.update(dict(self.model.named_buffers()))
        with platform.no_grad():
            for key, val in valid_sd.items():
                target = targets.get(key)
                if target is None:
                    continue
                if isinstance(target, DTensor):
                    val = _resolve_local_tensor(key, val, target)
                platform.load_into_param(target, val)

    def _load_hf_safetensors(self, weights_path: str, adapter_cls) -> None:
        """Load HF safetensors via spec's ``state_dict_adapter``; drop shape mismatches."""
        # Cast loaded params down to the checkpoint's advertised dtype so the
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
        # as plain (non-DTensor) tensors — e.g. Qwen3.5 GatedDeltaNet
        # ``conv1d`` / ``dt_bias`` / ``A_log`` under TP. The model is built on
        # meta and sliced before load, so without this the size-mismatched full
        # weight would be dropped (the shard then trains from random init).
        transform_fn = getattr(self.spec, "tp_load_transform_fn", None)
        if transform_fn is not None:
            for key, fn in transform_fn(self.model, self.mesh, self.args).items():
                if key in hf_sd:
                    hf_sd[key] = fn(hf_sd[key])
        valid_sd, dropped, missing, unexpected = self._validate_hf_state_dict(hf_sd)
        if dropped:
            logger.warning(
                "Dropped %d keys due to shape mismatch (first 5: %s)",
2164
2165
2166
2167
2168
2169
2170
2171
2172
                len(dropped), dropped[:5],
            )
        # Derive missing/unexpected ourselves — ``HSDPModule.load_state_dict``
        # returns ``None``.
        self._load_validated_state_dict(valid_sd)
        model_name = getattr(self.args.model, "name", "")
        logger.info_rank0(
            "HF (%s) load: %d tensors into hyper model",
            model_name, len(valid_sd),
2223
2224
2225
2226
2227
2228
2229
2230
2231
        # the FQN here. Covers the torch-native checkpoint_wrapper
        # (``_checkpoint_wrapped_module``), the hyper torch activation wrapper
        # (``_swap_wrapped_module``), and the hyper MindSpore activation wrapper
        # (``_ckpt_wrapped_module``); stripping an absent segment is a no-op.
        wrapper_segments = (
            "._checkpoint_wrapped_module",
            "._swap_wrapped_module",
            "._ckpt_wrapped_module",
        )
2243
2244
2245
2246
2247
2248
2249
2250
2251
        for hf_name, hf_tensor in hf_sd.items():
            real_name = logical_to_real.get(hf_name)
            if real_name is None:
                continue
            tgt = tuple(real_to_param[real_name].shape)
            src = tuple(hf_tensor.shape)
            if src == tgt:
                valid_sd[real_name] = hf_tensor
            else:
hyper_parallel/trainer/llm_trainer.py
51
52
53
54
55
56
57
58
59
60
61
62
        # 13 steps — call base's methods, override where needed
        self.base._setup()
        self.base._build_model()
        self.base._freeze_model()
        self._build_model_assets()
        self._build_data_transform()
        self.base._build_dataset()
        self._build_collate_fn()
        self.base._build_dataloader()
        self.base._build_parallelized_model()
        self.base._build_optimizer()
        self.base._build_lr_scheduler()
78
79
80
81
82
83
84
85
86
        built for them. Text formats (``hf_datasets`` / ``json_file``) load an
        HF AutoTokenizer from ``model.tokenizer_path`` or ``model.weights_path``.
        """
        data_type = getattr(self.base.args.data, 'type', 'dummy')
        if data_type in ('dummy', 'megatron', 'preset_pt'):
            self.base.tokenizer = None
            return

        # Try tokenizer_path first, fall back to weights_path
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226

        pad_id = 0
        if self.base.tokenizer and self.base.tokenizer.pad_token_id is not None:
            pad_id = self.base.tokenizer.pad_token_id
        seq_divisor = self.base.parallel_dims.seq_divisor

        def _lm_collate(batch: List[Dict[str, Any]]) -> Dict[str, Any]:
            """Pad sequences to max length in batch."""
            max_len = max(item["input_ids"].size(0) for item in batch)
            if seq_divisor > 1 and max_len % seq_divisor:
                max_len += seq_divisor - max_len % seq_divisor
            input_ids_list = []
            labels_list = []

            for item in batch:
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
                else:
                    input_ids_list.append(item["input_ids"])
                    labels_list.append(item["labels"])

            out = {
                "input_ids": torch.stack(input_ids_list),
                "labels": torch.stack(labels_list),
            }
            if "num_items_in_batch" in batch[0]:
                out["num_items_in_batch"] = sum(
                    int(item["num_items_in_batch"]) for item in batch
                )
            if "attention_mask" in batch[0]:
                masks = []
                for item in batch:
                    pad_len = max_len - item["attention_mask"].size(0)
                    masks.append(torch.nn.functional.pad(item["attention_mask"], (0, pad_len), value=0))
                out["attention_mask"] = torch.stack(masks)
            if "position_ids" in batch[0]:
                positions = []
                for item in batch:
                    pos = item["position_ids"]
                    pad_len = max_len - pos.shape[-1]
                    positions.append(torch.nn.functional.pad(pos, (0, pad_len), value=0))
                if positions[0].dim() == 1:
                    out["position_ids"] = torch.stack(positions)
                else:
                    out["position_ids"] = torch.stack(positions).transpose(0, 1).contiguous()
            return out

        self.base.collate_fn = _lm_collate

    # ------------------------------------------------------------------
hyper_parallel/trainer/parallel_dims.py
499
500
501
502
503
504
505
506
507
        SequenceParallel TP and context parallel both slice the sequence
        dim, so variable-length batches pad up to a multiple of ``tp * cp``
        (the trailing pad rides label ``-100`` and is masked out of the CE).
        """
        return self.tp * self.cp

    @property
    def tp_enabled(self) -> bool:
        """Return True if tensor parallelism is enabled (tp > 1)."""
hyper_parallel/trainer/vl_trainer.py
38
39
40
41
42
43
44
45
46
        self.base._build_model()
        self.base._freeze_model()
        self._build_model_assets()
        self._build_data_transform()
        self.base._build_dataset()
        self._build_collate_fn()
        self.base._build_dataloader()
        self.base._build_parallelized_model()
        self.base._build_optimizer()
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125

    def _build_data_transform(self):
        self.base.data_transform = None

    @staticmethod
    def _stack_positions(batch: List[Dict[str, Any]], key: str):
        values = [item[key] for item in batch]
        if values[0].dim() == 1:
            return torch.stack(values)
        return torch.stack(values).transpose(0, 1).contiguous()

    @staticmethod
    def _stack_or_cat_grids(batch: List[Dict[str, Any]], key: str):
        values = [item[key] for item in batch]
        if values[0].dim() == 1:
            return torch.stack(values)
        return torch.cat(values, dim=0)

    @staticmethod
    def _maybe_cat_optional(batch: List[Dict[str, Any]], key: str):
        if key in batch[0] and batch[0].get(key) is not None:
            return torch.cat([item[key] for item in batch], dim=0)
        return None

    def _vl_collate(self, batch: List[Dict[str, Any]]) -> Dict[str, Any]:
        """Collate VL tensor rows into a trainer batch."""
        out = {
            "input_ids": torch.stack([item["input_ids"] for item in batch]),
            "labels": torch.stack([item["labels"] for item in batch]),
            "attention_mask": torch.stack([item["attention_mask"] for item in batch]),
        }
        if "num_items_in_batch" in batch[0]:
            out["num_items_in_batch"] = sum(int(item["num_items_in_batch"]) for item in batch)
        if "mm_token_type_ids" in batch[0]:
            out["mm_token_type_ids"] = torch.stack([item["mm_token_type_ids"] for item in batch])
        if "position_ids" in batch[0]:
            out["position_ids"] = self._stack_positions(batch, "position_ids")
        for key in ("pixel_values", "pixel_values_videos"):
            value = self._maybe_cat_optional(batch, key)
            if value is not None:
                out[key] = value
        for key in ("image_grid_thw", "video_grid_thw"):
            if key in batch[0] and batch[0].get(key) is not None:
                out[key] = self._stack_or_cat_grids(batch, key)
        return out

    def _build_collate_fn(self):
        """Build collate fn (internal)."""
        self.base.collate_fn = self._vl_collate

    def train(self):
        """Run the full training loop by delegating to the underlying BaseTrainer."""
        return self.base.train()