Diff Coverage

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

Source File Diff Coverage (%) Missing Lines
hyper_parallel/core/pipeline_parallel/stage.py 4.8% 55-56,62-63,196-197,215,220-221,236-237,240-241,246-247,338,344,346,351,353
hyper_parallel/core/shard/ops/parallel_elementwise.py 28.6% 29,35-40,678-682,685-687
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 44.4% 44-47,50,53-55,65-71
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.8% 37,40,43,56-63,67,81-87,101-106,110-124,129-131,143-148,150-155,157,159-162,166
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/trainer/base.py 12.9% 144,148-156,164-165,168,184-185,193,197,204,217,219-220,231-232,237-241,268,286,291,326-328,333,339-340,357,362-363,374,378-379,382-395,397-398,414,420-423,425-426,439-441,445-446,450,463,465-467,492-494,521-522,527,537-538,543,548-550,560-568,578-580,585-587,595,598-601,616,619-620,627-628,632,638-640,643-649,651,657,662-663,665-666,693-694,707,712,715-716,759-761,763,771-772,774,776-780,782-785,791,793-801,804,806-812,815-821,824-826,828-843,845-846,850-859,959,961,963,1166,1177,1192-1195,1201,1216-1219,1224-1226,1228-1230,1232,1235,1240-1245,1322-1330,1333-1341,1343-1345,1347,1354-1355,1358-1367,1371-1378,1382-1391,1393,1399-1404,1408-1410,1414-1415,1419-1422,1424-1429,1440-1447,1449-1455,1459-1469,1474-1475,1489,1502-1503,1512-1514,1521,1523,1542-1552,1558-1559,1582-1584,1588-1590,1592-1598,1601-1604,1607-1613,1617-1625,1629-1630,1632-1636,1640-1648,1652,1657-1658,1662-1664,1666-1671,1675-1681,1685-1693,1701-1704,1708-1717,1721-1725,1729-1739,1743-1751,1771-1781,1799,1810,1892-1893,1897-1900,1917,1940,1983,1997-2000,2002-2010,2023,2027-2032,2112-2127,2144-2148,2157-2158,2216,2236
hyper_parallel/trainer/config.py 100%  
hyper_parallel/trainer/llm_trainer.py 0.0% 55-58,81-82,88,90,119,121-123,216,221-222,243,247-248,251-264,266-267
hyper_parallel/trainer/parallel_dims.py 87.5% 505
hyper_parallel/trainer/vl_trainer.py 0.0% 42,56,77-82,84-89,91-95,97,99,104-117,121
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
            param = shared_param_info.parameter
            # 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(param, "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
            # DTensor dispatch whitelists DistCommBroadcast and unwraps DTensor
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
        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):
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
                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)
334
335
336
337
338
339
340
341
342

    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):
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
            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
25
26
27
28
29
30
31
32
33
def _is_first_partial_rank(layout) -> bool:
    """Return whether this rank is the first coordinate of all Partial axes."""
    for mesh_dim, partial_type in enumerate(layout.partial):
        if partial_type is not None and layout.mesh.get_local_rank(mesh_dim) != 0:
            return False
    return True


def _has_default_add_alpha(args: tuple, kwargs: dict) -> bool:
31
32
33
34
35
36
37
38
39
40
41
42
43
44


def _has_default_add_alpha(args: tuple, kwargs: dict) -> bool:
    """Return whether add's optional alpha keeps the second operand unchanged."""
    if args:
        return False
    alpha = kwargs.get("alpha", 1)
    if hasattr(alpha, "shape"):
        return False
    return alpha == 1


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
687
688
689
        output_layout = infer_result[0][0]
        use_replicate_value = _is_first_partial_rank(output_layout)

        def _expand_impl1(x1, x2, *args, **kwargs):
            if use_replicate_value:
                return func(x1, x2, *args, **kwargs)
            if _has_default_add_alpha(args, kwargs):
                return x2
            return func(x1 * 0, x2, *args, **kwargs)

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

        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

    Picks ``vocab`` from ``model.config.vocab_size`` when available, else
    defaults to 32000 (matches the pre-refactor behaviour in BaseTrainer).
    """
    seq_len = args.data.max_seq_len
    vocab_size = base.model.config.vocab_size
    base_seed = args.train.seed
    total_samples = base.state.max_steps * args.train.global_batch_size
    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 = args.data.subset
    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 = args.data.train_size
    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(args.train.num_train_epochs 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 = data_cfg.train_path
    if not train_path:
        raise ValueError("data.train_path is required when data.type='megatron'")

    seq_length = int(data_cfg.max_seq_len)
    # ``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 = data_cfg.megatron_seed
    if megatron_seed is None:
        megatron_seed = train_cfg.seed
    seed = int(megatron_seed)
    pad_token_id = int(data_cfg.pad_token_id)
    eod_token_id = data_cfg.eod_token_id
    eod_mask_loss = bool(data_cfg.eod_mask_loss)
    mmap_bin = bool(data_cfg.mmap_bin_files)

    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.
    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
    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)
    if args.train.max_steps:
        base.state.max_steps = int(args.train.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 = args.train.max_steps
    global_bs = args.train.global_batch_size
    total_samples = max_steps * global_bs

    data_cfg = args.data
    model_cfg = args.model
    extra = model_cfg.config_overrides 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(data_cfg.vl_grid_t)
    grid_h = int(data_cfg.vl_grid_h)
    grid_w = int(data_cfg.vl_grid_w)
    image_token_id = int(data_cfg.image_token_id)
    video_token_id = int(data_cfg.video_token_id)
    is_video = bool(data_cfg.vl_video)
    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(data_cfg.max_seq_len), min_len)
    base_seed = int(args.train.seed)

    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/trainer/base.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
    # ------------------------------------------------------------------

    @property
    def _deterministic(self) -> bool:
        return bool(self.args.train.debug.deterministic)

    def _apply_pre_init_deterministic_env(self):
        """Pin HCCL / PYTHONHASHSEED before ``init_process_group`` boots the backend."""
        if not self._deterministic:
            return
        seed = self.args.train.seed
        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.
160
161
162
163
164
165
166
167
168
169
170
171
172

        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 = self.args.train.comm_backend
        init_process_group(backend=backend)

        local_rank = self.args.train.local_rank
        device_type = platform.device_type()  # "npu" or "cuda"
        # Use platform.device(idx) — backend-agnostic.
        self.device = platform.device(local_rank)
        device_handle = platform.get_device_handle(device_type)
180
181
182
183
184
185
186
187
188
189
        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 = self.args.train.mixed_precision
        needs_mp_wrap = bool(
            mp_cfg.enabled
            and mp_cfg.param_dtype not in ('float32', 'fp32')
        )
        # PP stages carry the dtype policy only through a per-stage FSDP wrap,
189
190
191
192
193
194
195
196
197
198
199
200
201
        # 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 "
200
201
202
203
204
205
206
207
208
                "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).
213
214
215
216
217
218
219
220
221
222
223
224
        self._dp_group_info = GroupInfo(
            group_name="trainer_dp", group=dp_group, rank_size=dp_size,
        )

        seed = self.args.train.seed
        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"):
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
                handle.manual_seed(seed)
        except Exception as exc:  # pylint: disable=W0718
            logger.warning("Device-side seed init skipped: %s", exc)

        if self._deterministic:
            warn_only = self.args.train.debug.deterministic_warn_only
            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",
264
265
266
267
268
269
270
271
        When ``args.runtime.init_device == "meta"``, the model is constructed on
        the meta device (no memory allocated) and real weights are loaded after
        FSDP sharding via ``_load_weights_after_parallel``.
        """
        init_device = self.args.train.init_device
        # Meta-device init: each rank materialises only its own shard
        # post-FSDP — pre-trained weights via DCP, otherwise random init.
        if init_device == "meta":
282
283
284
285
286
287
288
289
290
291
292
293
294
295

        # Cross-check parallel degrees against the actual model hyperparams
        # (heads%tp, kv_heads%tp, num_experts%ep, seq_len%(cp*tp)).
        # Fails fast here instead of crashing inside parallelize_module.
        seq_len = self.args.data.max_seq_len
        self.parallel_dims.validate_against_model(self.model, seq_len=seq_len)

    def _freeze_model(self):
        """Step 3: Freeze specified modules (optional)."""
        freeze_modules = self.args.model.freeze_modules
        if not freeze_modules:
            return
        for name, param in self.model.named_parameters():
            if any(pattern in name for pattern in freeze_modules):
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337

        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 self.args.data.streaming:
            # ``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."
335
336
337
338
339
340
341
342
343
344
                "_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 = self.args.data.type
        self.train_dataset = build_dataset(
            data_type,
            base=self,
            args=self.args,
            tokenizer=getattr(self, "tokenizer", None),
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
        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)
370
371
372
373
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
                )
                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):
410
411
412
413
414
415
416
417
418
        accumulation).
        """
        from torchdata.stateful_dataloader import StatefulDataLoader  # pylint: disable=C0415  # optional dep

        micro_bs = self.args.train.micro_batch_size

        # 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
416
417
418
419
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 = self.args.data.shuffle
        sampler_seed = self.args.train.seed

        self.sampler = DistributedSampler(
            self.train_dataset,
            num_replicas=dp_size,
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
        )

        # StatefulDataLoader supports state_dict() / load_state_dict()
        # for checkpoint resume (torchdata API, used by  + ).
        num_workers = self.args.data.num_workers
        prefetch_factor = self.args.data.prefetch_factor
        pin_memory = self.args.data.pin_memory

        # 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,
459
460
461
462
463
464
465
466
467
468
469
470
        }
        # 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(self.args.train.seed))
            loader_kwargs["generator"] = gen
        self.train_dataloader = StatefulDataLoader(
            self.train_dataset, **loader_kwargs,
        )
488
489
490
491
492
493
494
495
496
497
498
        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 "
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
        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
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
        # 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 = self.args.train.optimizer.loss_aggregation
        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``
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
        # *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(self.args.train.accelerator.pp_micro_batch_num)
        if micro_num < 1:
            raise ValueError(f"pp_micro_batch_num ({micro_num}) must be >= 1.")
        global_bs = self.args.train.global_batch_size
        micro_bs = int(self.args.train.micro_batch_size)
        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."
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
        # 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 = self.args.train.gradient_checkpointing.activation_checkpoint
        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 = self.args.train.checkpoint
        if ckpt_cfg.save_hf_weights:
            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."
            )
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
            )
        # 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 = self.args.train.init_device
        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."
612
613
614
615
616
617
618
619
620
621
622
623
624
            # 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
623
624
625
626
627
628
629
630
631
632
633
634
635
636
            # ``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
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
            # 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
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
            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,
        )
689
690
691
692
693
694
695
696
697
698
        ``k_norm`` (per text layer), the vision ``pos_embed`` and
        ``deepstack_merger_list`` included — so a complete load leaves nothing
        random.
        """
        init_device = self.args.train.init_device
        weights_path = self.args.model.weights_path
        if init_device == "meta":
            # Always materialize first (random init baseline) so no param
            # stays on meta — then overlay the checkpoint.
            self._materialize_and_init_shards()
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
        # 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)."""
        freeze_modules = self.args.model.freeze_modules
        if not freeze_modules:
            return
        mp_cfg = self.args.train.mixed_precision
        if not mp_cfg.enabled:
            return

        target_dtype = {
            'bfloat16': torch.bfloat16,
755
756
757
758
759
760
761
762
763
764
765
766
767
        )

    def _maybe_cast_trainable_params(self) -> None:
        """Cast trainable params to the configured mixed-precision storage dtype."""
        mp_cfg = self.args.train.mixed_precision
        if not mp_cfg.enabled:
            return

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

        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,
                )
787
788
789
790
791
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
                    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,
        )
955
956
957
958
959
960
961
962
963
964
965
966
967
        ``MixedPrecisionPolicy`` (param_dtype / reduce_dtype / output_dtype).
        No autocast context is entered — the model's own ``.float()`` /
        ``.to(weight.dtype)`` cast points handle the fp32 residual stream.
        """
        mp_cfg = self.args.train.mixed_precision
        self.model_fwd_context = nullcontext()
        self.model_bwd_context = nullcontext()
        self.grad_scaler = None
        if mp_cfg.enabled:
            logger.info_rank0(
                "Mixed precision via FSDP2 mp_policy: param=%s reduce=%s on %s",
                mp_cfg.param_dtype,
                mp_cfg.reduce_dtype,
1162
1163
1164
1165
1166
1167
1168
1169
1170
        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 = {
1173
1174
1175
1176
1177
1178
1179
1180
1181
        }
        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,
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
        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",
1197
1198
1199
1200
1201
1202
1203
1204
1205
            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,
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
        """Scale one micro-batch loss according to trainer loss aggregation."""
        dp_size = self.parallel_dims.dp_size
        agg = self.args.train.optimizer.loss_aggregation
        cp_size = int(self.parallel_dims.cp or 1)
        cp_rank_average = agg == "rank_average" and cp_size > 1
        if agg == 'rank_average' and not cp_rank_average:
            scaled_loss = loss / num_micro if num_micro > 1 else loss
            rank_average_loss_scale_size = getattr(
                self.model,
                "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,
            )
        tp_loss_scale_size = getattr(
            self.model,
            "hp_loss_tp_scale_size",
            max(1, self.parallel_dims.tp),
        )
        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,
        micro_batch: Dict[str, Any],
1318
1319
1320
1321
1322
1323
1324
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
        Returns:
            The CP-sharded micro-batch list (or the input unchanged when ``cp<=1``).
        """
        cp_size = int(self.parallel_dims.cp 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",
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
                        "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
        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):
        """Run forward/backward over accumulated micro-batches."""
        num_micro = len(micro_batches)
        total_loss_sum = 0.0
        total_loss_arith_sum = 0.0
        total_tokens_local = 0
        for index, micro_batch in enumerate(micro_batches):
            is_last = index == num_micro - 1
            if isinstance(self.model, HSDPModule):
                self.model.set_requires_gradient_sync(is_last)
                self.model.set_is_last_backward(is_last)
            self._maybe_toggle_reshard(index, num_micro)

            raw_loss, micro_tokens = self.forward_backward_step(
                micro_batch,
                token_counts[index],
                global_tokens,
                num_micro=num_micro,
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
                token_counts[index],
                global_tokens,
                num_micro=num_micro,
            )
            loss_value = raw_loss.item()
            total_loss_sum += loss_value * micro_tokens
            total_loss_arith_sum += loss_value
            total_tokens_local += micro_tokens
            self.on_substep_end()
        return total_loss_sum, total_loss_arith_sum, total_tokens_local

    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_
        return clip_fn(self.model.parameters(), max_grad_norm)

    def _optimizer_step_after_backward(self, clip_fn):
        """Clip gradients if enabled, run optimizer/scheduler, and clear grads."""
        max_grad_norm = float(self.args.train.optimizer.max_grad_norm)
        grad_norm = clip_fn(max_grad_norm) if max_grad_norm > 0.0 else None
        grad_norm_value = None if grad_norm is None else grad_norm.item()
        self.on_pre_optimizer_step(grad_norm=grad_norm_value)

        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(
        self,
        total_loss_sum: float,
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
        global_tokens: int,
        num_micro: int,
    ) -> float:
        """Aggregate the reported non-pipeline loss across DP ranks."""
        agg = self.args.train.optimizer.loss_aggregation
        cp_size = int(self.parallel_dims.cp 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
        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."""
        if int(self.parallel_dims.tp or 1) > 1 and int(self.parallel_dims.ep or 1) > 1:
            return avg_loss
        ep_size = int(self.parallel_dims.ep or 1)
        if ep_size <= 1:
            return avg_loss
        try:
            ep_group = self.mesh.get_group("ep")
        except (KeyError, ValueError):
            return avg_loss
        metric = platform.full((1,), avg_loss).to(self.device)
        ep_group_info = GroupInfo(
            group_name="trainer_ep_metric",
            group=ep_group,
            rank_size=ep_size,
        )
        platform.all_reduce(metric, ep_group_info)
        return metric.item() / ep_size

    def train_step(self, data_iterator):
        """Execute one training step with gradient accumulation.
1485
1486
1487
1488
1489
1490
1491
1492
1493
        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 = [
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
        self.state.global_step += 1
        num_micro = len(micro_batches)

        token_counts = [count_loss_token(mb) for mb in micro_batches]
        _, global_tokens = self._collect_global_tokens(token_counts)
        total_loss_sum, total_loss_arith_sum, total_tokens_local = self._run_micro_batches(
            micro_batches,
            token_counts,
            global_tokens,
        )
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518

        # Wait for async gradient reduce
        #
        hsdp_sync_stream()
        self._run_post_fsdp_grad_reduce()
        grad_norm_value = self._optimizer_step_after_backward(self._non_pp_clip_grad_norm)
        avg_loss = self._aggregate_non_pp_loss(
            total_loss_sum,
            total_loss_arith_sum,
            total_tokens_local,
            global_tokens,
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
            total_tokens_local,
            global_tokens,
            num_micro,
        )
        avg_loss = self._average_model_parallel_metric(avg_loss)

        return {"loss": avg_loss, "grad_norm": grad_norm_value}

    @staticmethod
    def _pp_concat_micro_batches(micro_batches):
        """Concatenate grad-accum micro-batches into one global batch (dim 0).
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
        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."
1554
1555
1556
1557
1558
1559
1560
1561
1562
                    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.
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
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

        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 = self.args.train.optimizer.loss_aggregation
        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."
        )
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
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
        )

    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``).
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
        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.
1795
1796
1797
1798
1799
1800
1801
1802
1803
        )
        # on_train_begin runs checkpoint resume — state.global_step may be
        # updated to the resumed step before the loop starts.
        self.on_train_begin()
        num_epochs = self.args.train.num_train_epochs

        if self.state.global_step > 0:
            logger.info_rank0(
                "Resuming training from step %d", self.state.global_step,
1806
1807
1808
1809
1810
1811
1812
1813
1814
        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.
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
                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).
1913
1914
1915
1916
1917
1918
1919
1920
        else:
            dp_mesh = self.mesh
        kwargs = {"mesh": dp_mesh}

        reshard = self.args.train.accelerator.reshard_after_forward
        kwargs["reshard_after_forward"] = reshard

        return kwargs
1936
1937
1938
1939
1940
1941
1942
1943
1944
                           self.parallel_dims.ep)
            return self._build_fsdp_kwargs()

        kwargs = {"mesh": ep_mesh}
        reshard = self.args.train.accelerator.reshard_after_forward
        kwargs["reshard_after_forward"] = reshard
        return kwargs

    def _materialize_and_init_shards(self) -> None:
1979
1980
1981
1982
1983
1984
1985
1986
1987
        # 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(self.parallel_dims.pp) <= 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).
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
        )

    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.
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
        ``_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
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131

        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 checkpoint safetensors via spec's ``state_dict_adapter``; drop shape mismatches."""
        # Cast loaded params down to the checkpoint's advertised dtype so the
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
        # sliced manually 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)",
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
                len(dropped), dropped[:5],
            )
        # Derive missing/unexpected ourselves — ``HSDPModule.load_state_dict``
        # returns ``None``.
        self._load_validated_state_dict(valid_sd)
        model_name = self.args.model.name
        logger.info_rank0(
            "HF (%s) load: %d tensors into hyper model",
            model_name, len(valid_sd),
        )
2212
2213
2214
2215
2216
2217
2218
2219
2220
        # 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",
        )
2232
2233
2234
2235
2236
2237
2238
2239
2240
        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()
77
78
79
80
81
82
83
84
85
86
        ``preset_pt`` replayed tensors) carry no raw text, so no tokenizer is
        built for them. Text formats (``hf_datasets`` / ``json_file``) load an
        HF AutoTokenizer from ``model.tokenizer_path`` or ``model.weights_path``.
        """
        data_type = self.base.args.data.type
        if data_type in ('dummy', 'megatron', 'preset_pt'):
            self.base.tokenizer = None
            return

        # Try tokenizer_path first, fall back to weights_path
84
85
86
87
88
89
90
91
92
93
94
            return

        # Try tokenizer_path first, fall back to weights_path
        model_cfg = self.base.args.model
        tokenizer_path = model_cfg.tokenizer_path
        if not tokenizer_path:
            tokenizer_path = model_cfg.weights_path

        if not tokenizer_path:
            raise ValueError(
                "data.type='hf_datasets' requires model.tokenizer_path or "
115
116
117
118
119
120
121
122
123
124
125
126
        if self.base.tokenizer is None:
            self.base.data_transform = None
            return

        max_seq_len = self.base.args.data.max_seq_len
        tokenizer = self.base.tokenizer
        text_key = self.base.args.data.text_key
        data_type = self.base.args.data.type
        template = self.base.args.data.template

        def _tokenize_fn(examples):
            """Tokenize text and create causal LM labels.
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
501
502
503
504
505
506
507
508
509
        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()
52
53
54
55
56
57
58
59
60
    def _build_model_assets(self):
        """Load processor when a real VL dataset is configured."""
        self.base.processor = None
        self.base.tokenizer = None
        data_type = self.base.args.data.type
        if data_type == "vl_dummy":
            return
        processor_path = (
            getattr(self.base.args.data, "processor_path", None)
 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()