Diff Coverage

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

Source File Diff Coverage (%) Missing Lines
hyper_parallel/data/__init__.py 100%  
hyper_parallel/data/hf.py 17.1% 56-63,81,98-100,103-115,118-119,123,127,131-136,140-145,157,160-161,175-179,184-190,204,206,210,212-213,218-228,230-233,235-239,244,248,268-269,280,301
hyper_parallel/trainer/base.py 6.9% 341,421,438,446-449,457-459,478-483,488,1864-1865,1960-1967
hyper_parallel/trainer/config.py 100%  
hyper_parallel/trainer/vl_trainer.py 3.8% 28-32,37-41,46-48,50,53,55-59,63-69,73-81,85-92,96-100,102-105,109,117-122,124-132,137-139,142,148-154,156-159,161-166,168-171,175-178,183,191-195,197-199,204,206-211,218-220,225-227,229,281-284
hyper_parallel/data/hf.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67


def _tensorize_item(item: dict[str, Any]) -> dict[str, Any]:
    """Convert known HF row fields to tensors while preserving scalars."""
    out: dict[str, Any] = {}
    for key, dtype in _HF_TENSOR_DTYPES.items():
        value = item.get(key)
        if value is not None:
            out[key] = torch.as_tensor(value, dtype=dtype)
    if "num_items_in_batch" in item:
        out["num_items_in_batch"] = int(item["num_items_in_batch"])
    return out


class TokenizedDataset(Dataset):
    """Lightweight ``torch.utils.data.Dataset`` view over an HF dataset.
77
78
79
80
81
82
83
84
85
    def __len__(self) -> int:
        return len(self.data)

    def __getitem__(self, idx: int):
        return _tensorize_item(self.data[idx])


class StreamingTokenizedDataset(IterableDataset):
    """Streaming ``IterableDataset`` wrapper over an HF iterable dataset.
 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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
        logical_length: Optional per-rank sample cap for one logical epoch.
    """

    def __init__(self, hf_ds: Any, logical_length: Optional[int] = None) -> None:
        self.data = hf_ds
        self.logical_length = logical_length
        self._epoch = 0

    def __iter__(self):
        if hasattr(self.data, "set_epoch"):
            self.data.set_epoch(self._epoch)
        yielded = 0
        while self.logical_length is None or yielded < self.logical_length:
            made_progress = False
            for item in self.data:
                made_progress = True
                if self.logical_length is not None and yielded >= self.logical_length:
                    break
                yielded += 1
                yield _tensorize_item(item)
            if not made_progress:
                break

    def __len__(self) -> int:
        if self.logical_length is None:
            raise TypeError(
                "Streaming dataset has no static length. Set data.train_size "
                "to define a logical per-epoch sample budget."
            )
        return self.logical_length

    def set_epoch(self, epoch: int) -> None:
        """Record the current epoch for deterministic HF shuffle reset."""
        self._epoch = int(epoch)

    def state_dict(self) -> dict[str, Any]:
        """Return resumable iterator state for ``StatefulDataLoader``."""
        state = {"_epoch": self._epoch}
        if hasattr(self.data, "set_epoch"):
            self.data.set_epoch(self._epoch)
        if hasattr(self.data, "state_dict"):
            state["hf_state"] = self.data.state_dict()
        return state

    def load_state_dict(self, state: dict[str, Any]) -> None:
        """Restore iterator state produced by :meth:`state_dict`."""
        self._epoch = int(state.get("_epoch", 0))
        if hasattr(self.data, "set_epoch"):
            self.data.set_epoch(self._epoch)
        hf_state = state.get("hf_state")
        if hf_state is not None and hasattr(self.data, "load_state_dict"):
            self.data.load_state_dict(hf_state)


def _load_raw(args: Any, data_type: str, *, streaming: bool = False) -> Any:
    """Run the appropriate ``load_dataset`` call for ``data_type``."""
153
154
155
156
157
158
159
160
161
162
163
164
165
    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", streaming=streaming)
    subset = args.data.subset
    if subset:
        return load_dataset(train_path, subset, split="train", streaming=streaming)
    return load_dataset(train_path, split="train", streaming=streaming)


def _maybe_truncate(ds: Any, args: Any) -> Any:
    """Apply ``data.train_size`` if it shrinks the dataset."""
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194


def _maybe_clip_max_steps(*, base: Any, args: Any, global_sample_count: Optional[int]) -> None:
    """Clip ``base.state.max_steps`` when a finite global dataset size is known."""
    if global_sample_count is None:
        return
    steps_per_epoch = global_sample_count // max(args.train.global_batch_size, 1)
    num_epochs = max(int(args.train.num_train_epochs or 1), 1)
    base.state.max_steps = min(args.train.max_steps, num_epochs * steps_per_epoch)


def _per_rank_train_size(train_size: Optional[int], dp_rank: int, dp_size: int) -> Optional[int]:
    """Split a global logical sample budget across DP ranks."""
    if train_size is None:
        return None
    if train_size < 0:
        raise ValueError(f"data.train_size must be >= 0, but got {train_size}")
    base = train_size // max(dp_size, 1)
    remainder = train_size % max(dp_size, 1)
    return base + (1 if dp_rank < remainder else 0)


def _build_hf_streaming(
    *,
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
    dp_size: int,
    **_: Any,
) -> StreamingTokenizedDataset:
    """Build a streaming HF dataset with DP-rank self-sharding."""
    from datasets.distributed import split_dataset_by_node  # pylint: disable=C0415  # optional dep

    logger.info(
        "Loading streaming dataset: type=%s, path=%s, dp_rank=%d/%d",
        data_type, args.data.train_path, dp_rank, dp_size,
    )
    ds = _load_raw(args, data_type, streaming=True)

    if data_transform is not None:
        map_kwargs = {
            "batched": True,
            "remove_columns": ds.column_names,
            "desc": "Tokenizing",
        }
        try:
            ds = ds.map(data_transform, **map_kwargs)
        except TypeError as exc:
            if "unexpected keyword argument 'desc'" not in str(exc):
                raise
            map_kwargs.pop("desc", None)
            ds = ds.map(data_transform, **map_kwargs)
    column_names = getattr(ds, "column_names", []) or []
    if "input_ids" in column_names:
        ds = ds.filter(lambda x: len(x["input_ids"]) > 0)
    if args.data.shuffle:
        # Keep a moderate fixed buffer until a dedicated config knob is needed.
        ds = ds.shuffle(seed=int(args.train.seed), buffer_size=10_000)
    ds = split_dataset_by_node(ds, rank=dp_rank, world_size=dp_size)
    if hasattr(ds, "with_format"):
        ds = ds.with_format("torch")

    global_train_size = args.data.train_size
    local_train_size = _per_rank_train_size(global_train_size, dp_rank, dp_size)
    _maybe_clip_max_steps(base=base, args=args, global_sample_count=global_train_size)
    if global_train_size is not None:
        logger.info(
            "Streaming dataset logical epoch budget: global=%d, local=%d",
            global_train_size, local_train_size,
        )
    else:
        logger.info(
            "Streaming dataset uses source exhaustion for epoch boundaries; "
            "set data.train_size to define a logical sample budget."
        )
    return StreamingTokenizedDataset(ds, logical_length=local_train_size)


def _build_hf(
    *,
264
265
266
267
268
269
270
271
272
273
    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.
    """
    if args.data.streaming:
        return _build_hf_streaming(
            base=base,
            args=args,
            data_transform=data_transform,
            data_type=data_type,
276
277
278
279
280
281
282
283
284
        )
    logger.info(
        "Loading dataset: type=%s, path=%s", data_type, args.data.train_path,
    )
    ds = _maybe_truncate(_load_raw(args, data_type, streaming=False), args)

    if data_transform is not None:
        ds = ds.map(
            data_transform,
297
298
299
300
301
302
303
304
305
    # 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.
    _maybe_clip_max_steps(base=base, args=args, global_sample_count=len(wrapped))
    logger.info(
        "Dataset ready: %d samples, max_steps=%d",
        len(wrapped), base.state.max_steps,
    )
hyper_parallel/trainer/base.py
337
338
339
340
341
342
343
344
345
        """
        if getattr(self, "train_dataset", None) is not None:
            return
        data_type = self.args.data.type
        dp_rank, dp_size = self._get_dp_shard_rank_and_size()
        self.train_dataset = build_dataset(
            data_type,
            base=self,
            args=self.args,
417
418
419
420
421
422
423
424
425

        micro_bs = self.args.train.micro_batch_size

        # Sampler uses DP rank/size — TP/CP/PP/EP peers share data.
        dp_rank, dp_size = self._get_dp_shard_rank_and_size()

        # StatefulDataLoader supports state_dict() / load_state_dict()
        # for checkpoint resume (torchdata API, used by  + ).
        num_workers = self.args.data.num_workers
434
435
436
437
438
439
440
441
442
                num_workers,
            )
            num_workers = 0

        is_iterable_dataset = isinstance(self.train_dataset, IterableDataset)
        loader_kwargs = {
            "batch_size": micro_bs,
            "collate_fn": self.collate_fn,
            "num_workers": num_workers,
442
443
444
445
446
447
448
449
450
451
452
453
            "num_workers": num_workers,
            "pin_memory": pin_memory,
            "drop_last": True,
        }
        if not is_iterable_dataset:
            shuffle = self.args.data.shuffle
            sampler_seed = self.args.train.seed
            self.sampler = DistributedSampler(
                self.train_dataset,
                num_replicas=dp_size,
                rank=dp_rank,
                shuffle=shuffle,
453
454
455
456
457
458
459
460
461
462
463
                shuffle=shuffle,
                seed=sampler_seed,
                drop_last=True,
            )
            loader_kwargs["sampler"] = self.sampler
        elif hasattr(self, 'sampler'):
            delattr(self, 'sampler')
        # 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:
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
            self.args.train.global_batch_size // (micro_bs * dp_size),
            1,
        )

        try:
            dataset_size = len(self.train_dataset)
        except TypeError:
            dataset_size = None
        if dataset_size is None:
            logger.info_rank0(
                "Dataloader built: micro_bs=%d, grad_accum=%d, dataset_size=streaming/unknown",
                micro_bs, self._grad_accum,
            )
        else:
            logger.info_rank0(
                "Dataloader built: micro_bs=%d, grad_accum=%d, dataset_size=%d",
                micro_bs, self._grad_accum, dataset_size,
            )
1860
1861
1862
1863
1864
1865
1866
1867
1868
        for epoch in range(num_epochs):
            if self.state.global_step >= self.state.max_steps:
                break
            self.state.epoch = epoch
            if hasattr(self.train_dataset, 'set_epoch'):
                self.train_dataset.set_epoch(epoch)
            if hasattr(self, 'sampler'):
                self.sampler.set_epoch(epoch)
            self.on_epoch_begin()
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
            return None

    def _get_dp_shard_rank_and_size(self) -> tuple[int, int]:
        """Return the DP rank/size used to partition training 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
        return dp_rank, dp_size

    def _build_fsdp_kwargs(self) -> dict:
        """Build kwargs for ``fully_shard`` calls (dense parameters).
hyper_parallel/trainer/vl_trainer.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 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
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113


def _as_media_list(value: Any) -> List[Any]:
    """Normalize one-or-many media references to a list."""
    if value is None:
        return []
    if isinstance(value, (list, tuple)):
        return list(value)
    return [value]


def _iter_batched_rows(examples: Dict[str, List[Any]]) -> List[Dict[str, Any]]:
    """Convert an HF ``map(batched=True)`` payload into row dictionaries."""
    if not examples:
        return []
    first_key = next(iter(examples))
    size = len(examples[first_key])
    return [{key: value[idx] for key, value in examples.items()} for idx in range(size)]


def _build_vl_messages(row: Dict[str, Any], data_cfg: Any) -> List[Dict[str, Any]]:
    """Normalize one dataset row into a multimodal chat-template message list."""
    messages_key = getattr(data_cfg, "messages_key", "messages")
    image_key = getattr(data_cfg, "image_key", "image")
    text_key = getattr(data_cfg, "text_key", "text")

    image_queue = _as_media_list(
        row.get(image_key, row.get("images", row.get("image"))),
    )
    video_queue = _as_media_list(row.get("videos", row.get("video")))

    def _normalize_content(content: Any) -> List[Dict[str, Any]]:
        if isinstance(content, str):
            return [{"type": "text", "text": content}]
        if not isinstance(content, list):
            raise ValueError(
                "VL HuggingFace rows must provide list-style message content "
                f"or plain text, but got {type(content)}."
            )
        out: List[Dict[str, Any]] = []
        for item in content:
            if isinstance(item, str):
                out.append({"type": "text", "text": item})
                continue
            if not isinstance(item, dict):
                raise ValueError(
                    "VL message content items must be dict/str, "
                    f"but got {type(item)}."
                )
            item_type = item.get("type", "text")
            if item_type == "text":
                out.append({"type": "text", "text": item.get("text", item.get("content", ""))})
                continue
            if item_type == "image":
                image = item.get("image", item.get("path", item.get("url")))
                if image is None:
                    if not image_queue:
                        raise ValueError(
                            "VL message declared an image placeholder but no "
                            f"row-level media was found in '{image_key}'."
                        )
                    image = image_queue.pop(0)
                out.append({"type": "image", "image": image})
                continue
            if item_type == "video":
                video = item.get("video", item.get("path", item.get("url")))
                if video is None:
                    if not video_queue:
                        raise ValueError(
                            "VL message declared a video placeholder but no "
                            "row-level video field was found."
                        )
                    video = video_queue.pop(0)
                out.append({"type": "video", "video": video})
                continue
            raise ValueError(f"Unsupported VL content type: {item_type}")
        return out

    if row.get(messages_key) is not None:
        messages = row[messages_key]
        if not isinstance(messages, list):
            raise ValueError(
                f"data.messages_key='{messages_key}' must point to a list, "
                f"but got {type(messages)}."
            )
        return [
            {
                "role": message.get("role", "user"),
                "content": _normalize_content(message.get("content", "")),
            }
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
            }
            for message in messages
        ]

    user_text = row.get(text_key)
    if user_text is None and row.get("instruction") is not None:
        user_text = str(row["instruction"])
        if row.get("input"):
            user_text += f"\n{row['input']}"
    assistant_text = row.get("output")

    user_content: List[Dict[str, Any]] = []
    for image in image_queue:
        user_content.append({"type": "image", "image": image})
    for video in video_queue:
        user_content.append({"type": "video", "video": video})
    if user_text is not None:
        user_content.append({"type": "text", "text": str(user_text)})
    if not user_content:
        raise ValueError(
            "VL HuggingFace row must provide either messages, text/instruction, "
            "or media fields."
        )

    messages: List[Dict[str, Any]] = [{"role": "user", "content": user_content}]
    if assistant_text is not None:
        messages.append(
            {"role": "assistant", "content": [{"type": "text", "text": str(assistant_text)}]},
        )
    return messages


def _normalize_vl_processor_output(processed: Dict[str, Any]) -> Dict[str, Any]:
    """Convert processor tensors to per-sample Python lists."""
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
175
176
177
178
179
180
181
182
183
184
185
186
187

def _normalize_vl_processor_output(processed: Dict[str, Any]) -> Dict[str, Any]:
    """Convert processor tensors to per-sample Python lists."""

    def _squeeze_sequence(value: Any) -> Any:
        if torch.is_tensor(value):
            if value.dim() == 2 and value.shape[0] == 1:
                return value[0]
            if value.dim() == 3 and value.shape[1] == 1:
                return value[:, 0]
        return value

    def _squeeze_media(value: Any) -> Any:
        if torch.is_tensor(value) and value.dim() >= 3 and value.shape[0] == 1:
            return value[0]
        return value

    def _to_python(value: Any) -> Any:
        if torch.is_tensor(value):
            return value.detach().cpu().tolist()
        if hasattr(value, "tolist") and not isinstance(value, (list, tuple, dict, str)):
            return value.tolist()
        return value

    input_ids = _squeeze_sequence(processed["input_ids"])
    attention_mask = _squeeze_sequence(processed.get("attention_mask"))
    input_ids_list = _to_python(input_ids)
    record = {
        "input_ids": input_ids_list,
        "labels": list(input_ids_list),
    }
    if attention_mask is not None:
        attention_mask_list = _to_python(attention_mask)
        record["attention_mask"] = attention_mask_list
        record["labels"] = [
            token if mask else -100
            for token, mask in zip(record["labels"], attention_mask_list)
        ]

    for key in (
        "pixel_values",
        "pixel_values_videos",
        "image_grid_thw",
        "video_grid_thw",
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
        "video_grid_thw",
        "position_ids",
        "mm_token_type_ids",
    ):
        value = processed.get(key)
        if value is None:
            continue
        if key in ("pixel_values", "pixel_values_videos", "image_grid_thw", "video_grid_thw"):
            value = _squeeze_media(value)
        else:
            value = _squeeze_sequence(value)
        record[key] = _to_python(value)
    return record


def _build_vl_hf_transform(processor: Any, data_cfg: Any):
    """Create the per-row HuggingFace map transform for real VL datasets."""
    max_seq_len = int(getattr(data_cfg, "max_seq_len", 0) or 0)

    def _transform(examples: Dict[str, List[Any]]) -> Dict[str, List[Any]]:
        rows = _iter_batched_rows(examples)
        batch_out: Dict[str, List[Any]] = {}
        for row in rows:
            messages = _build_vl_messages(row, data_cfg)
            processed = processor.apply_chat_template(
                messages,
                tokenize=True,
                add_generation_prompt=False,
                return_dict=True,
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
                add_generation_prompt=False,
                return_dict=True,
                return_tensors="pt",
            )
            record = _normalize_vl_processor_output(processed)
            if max_seq_len and len(record["input_ids"]) > max_seq_len:
                raise ValueError(
                    "VL HuggingFace sample length exceeds data.max_seq_len. "
                    "Automatic truncation is disabled because it can desync "
                    "vision placeholders and media features."
                )
            for key, value in record.items():
                batch_out.setdefault(key, []).append(value)
        return batch_out

    return _transform


class VLTrainer:
    """Trainer for multimodal Qwen3-VL training (text + image/video).
277
278
279
280
281
282
283
284
285
286
287
288
        self.base.tokenizer = getattr(self.base.processor, "tokenizer", None)
        logger.info("Processor loaded from %s", processor_path)

    def _build_data_transform(self):
        if self.base.processor is None:
            self.base.data_transform = None
            return
        self.base.data_transform = _build_vl_hf_transform(
            self.base.processor, self.base.args.data,
        )

    @staticmethod