|
25 | 25 |
|
26 | 26 | import argparse |
27 | 27 | import hashlib |
28 | | -import io |
29 | 28 | import json |
30 | 29 | import logging |
31 | 30 | import pickle |
|
36 | 35 | from typing import Any |
37 | 36 | from urllib.parse import urlparse |
38 | 37 |
|
| 38 | +import webdataset as wds |
| 39 | + |
39 | 40 | from megatron.bridge.data.energon import prepare_webdataset |
40 | 41 |
|
41 | 42 |
|
@@ -197,52 +198,34 @@ def read(self, reference: str) -> bytes: |
197 | 198 | ) |
198 | 199 |
|
199 | 200 |
|
200 | | -class SplitShardWriter: |
201 | | - """Write deterministic split-prefixed WebDataset tar shards.""" |
202 | | - |
203 | | - def __init__(self, output_dir: Path, split: str, max_samples_per_tar: int) -> None: |
204 | | - self.output_dir = output_dir |
205 | | - self.split = split |
206 | | - self.max_samples_per_tar = max_samples_per_tar |
207 | | - self._archive: tarfile.TarFile | None = None |
208 | | - self._shard_index = 0 |
209 | | - self._samples_in_shard = 0 |
210 | | - |
211 | | - def close(self) -> None: |
212 | | - """Close the current shard, if one was opened.""" |
213 | | - if self._archive is not None: |
214 | | - self._archive.close() |
215 | | - self._archive = None |
| 201 | +def _make_shard_writer(output_dir: Path, split: str, max_samples_per_tar: int) -> wds.ShardWriter: |
| 202 | + """Create the standard WebDataset writer with deterministic tar metadata.""" |
| 203 | + return wds.ShardWriter( |
| 204 | + str(output_dir / f"{split}-shard-%06d.tar"), |
| 205 | + maxcount=max_samples_per_tar, |
| 206 | + maxsize=float("inf"), |
| 207 | + verbose=0, |
| 208 | + encoder=False, |
| 209 | + mtime=0, |
| 210 | + mode=0o644, |
| 211 | + user="", |
| 212 | + group="", |
| 213 | + ) |
216 | 214 |
|
217 | | - def __enter__(self) -> SplitShardWriter: |
218 | | - return self |
219 | 215 |
|
220 | | - def __exit__(self, *args: object) -> None: |
221 | | - self.close() |
222 | | - |
223 | | - def _open_shard(self) -> None: |
224 | | - self.close() |
225 | | - shard_path = self.output_dir / f"{self.split}-shard-{self._shard_index:06d}.tar" |
226 | | - self._archive = tarfile.open(shard_path, "w") |
227 | | - self._shard_index += 1 |
228 | | - self._samples_in_shard = 0 |
229 | | - |
230 | | - def _add_bytes(self, name: str, payload: bytes) -> None: |
231 | | - if self._archive is None: |
232 | | - raise RuntimeError("Shard writer has no open archive.") |
233 | | - info = tarfile.TarInfo(name=name) |
234 | | - info.size = len(payload) |
235 | | - info.mtime = 0 |
236 | | - info.mode = 0o644 |
237 | | - self._archive.addfile(info, io.BytesIO(payload)) |
238 | | - |
239 | | - def write(self, key: str, images: list[bytes], messages: list[dict[str, Any]]) -> None: |
240 | | - """Write one matching-key media list and conversation.""" |
241 | | - if self._archive is None or self._samples_in_shard >= self.max_samples_per_tar: |
242 | | - self._open_shard() |
243 | | - self._add_bytes(f"{key}.jpgs", pickle.dumps(images, protocol=4)) |
244 | | - self._add_bytes(f"{key}.json", json.dumps(messages, ensure_ascii=False).encode("utf-8")) |
245 | | - self._samples_in_shard += 1 |
| 216 | +def _write_sample( |
| 217 | + writer: wds.ShardWriter, |
| 218 | + key: str, |
| 219 | + images: list[bytes], |
| 220 | + messages: list[dict[str, Any]], |
| 221 | +) -> None: |
| 222 | + writer.write( |
| 223 | + { |
| 224 | + "__key__": key, |
| 225 | + "jpgs": pickle.dumps(images, protocol=4), |
| 226 | + "json": json.dumps(messages, ensure_ascii=False).encode("utf-8"), |
| 227 | + } |
| 228 | + ) |
246 | 229 |
|
247 | 230 |
|
248 | 231 | def _sample_split(key: str, validation_fraction: float) -> str: |
@@ -357,46 +340,49 @@ def convert_subsets( |
357 | 340 | seen_keys: set[str] = set() |
358 | 341 | total = 0 |
359 | 342 |
|
360 | | - with SplitShardWriter(output_dir, "train", max_samples_per_tar) as train_writer: |
361 | | - with SplitShardWriter(output_dir, "val", max_samples_per_tar) as val_writer: |
362 | | - writers = {"train": train_writer, "val": val_writer} |
363 | | - for subset in subsets: |
364 | | - subset_dir = (source_dir / subset).resolve() |
365 | | - if not subset_dir.is_relative_to(source_dir) or not subset_dir.is_dir(): |
366 | | - raise FileNotFoundError(f"Downloaded subset directory does not exist: {subset_dir}") |
367 | | - jsonl_path = _jsonl_path(subset_dir, subset) |
368 | | - with ( |
369 | | - MediaResolver( |
370 | | - subset_dir, |
371 | | - allow_loose_media=allow_loose_media, |
372 | | - archive_paths=None if media_archives is None else media_archives.get(subset, ()), |
373 | | - ) as resolver, |
374 | | - jsonl_path.open(encoding="utf-8") as jsonl_file, |
375 | | - ): |
376 | | - for line_number, line in enumerate(jsonl_file, start=1): |
377 | | - if max_samples is not None and total >= max_samples: |
378 | | - break |
379 | | - if not line.strip(): |
380 | | - continue |
381 | | - row = json.loads(line) |
382 | | - sample_id = row.get("id") |
383 | | - if not isinstance(sample_id, str) or not sample_id: |
384 | | - raise ValueError(f"{jsonl_path}:{line_number}: row id must be a non-empty string.") |
385 | | - key = _sample_key(subset, sample_id) |
386 | | - if key in seen_keys: |
387 | | - raise ValueError(f"{jsonl_path}:{line_number}: duplicate normalized sample key {key!r}.") |
388 | | - seen_keys.add(key) |
389 | | - messages, images = _normalize_messages( |
390 | | - row.get("messages"), |
391 | | - resolver=resolver, |
392 | | - sample_name=f"{jsonl_path}:{line_number}", |
| 343 | + writers: dict[str, wds.ShardWriter] = {} |
| 344 | + with ExitStack() as writer_stack: |
| 345 | + for subset in subsets: |
| 346 | + subset_dir = (source_dir / subset).resolve() |
| 347 | + if not subset_dir.is_relative_to(source_dir) or not subset_dir.is_dir(): |
| 348 | + raise FileNotFoundError(f"Downloaded subset directory does not exist: {subset_dir}") |
| 349 | + jsonl_path = _jsonl_path(subset_dir, subset) |
| 350 | + with ( |
| 351 | + MediaResolver( |
| 352 | + subset_dir, |
| 353 | + allow_loose_media=allow_loose_media, |
| 354 | + archive_paths=None if media_archives is None else media_archives.get(subset, ()), |
| 355 | + ) as resolver, |
| 356 | + jsonl_path.open(encoding="utf-8") as jsonl_file, |
| 357 | + ): |
| 358 | + for line_number, line in enumerate(jsonl_file, start=1): |
| 359 | + if max_samples is not None and total >= max_samples: |
| 360 | + break |
| 361 | + if not line.strip(): |
| 362 | + continue |
| 363 | + row = json.loads(line) |
| 364 | + sample_id = row.get("id") |
| 365 | + if not isinstance(sample_id, str) or not sample_id: |
| 366 | + raise ValueError(f"{jsonl_path}:{line_number}: row id must be a non-empty string.") |
| 367 | + key = _sample_key(subset, sample_id) |
| 368 | + if key in seen_keys: |
| 369 | + raise ValueError(f"{jsonl_path}:{line_number}: duplicate normalized sample key {key!r}.") |
| 370 | + seen_keys.add(key) |
| 371 | + messages, images = _normalize_messages( |
| 372 | + row.get("messages"), |
| 373 | + resolver=resolver, |
| 374 | + sample_name=f"{jsonl_path}:{line_number}", |
| 375 | + ) |
| 376 | + split = _sample_split(key, validation_fraction) |
| 377 | + if split not in writers: |
| 378 | + writers[split] = writer_stack.enter_context( |
| 379 | + _make_shard_writer(output_dir, split, max_samples_per_tar) |
393 | 380 | ) |
394 | | - split = _sample_split(key, validation_fraction) |
395 | | - writers[split].write(key, images, messages) |
396 | | - counts[split] += 1 |
397 | | - total += 1 |
398 | | - if max_samples is not None and total >= max_samples: |
399 | | - break |
| 381 | + _write_sample(writers[split], key, images, messages) |
| 382 | + counts[split] += 1 |
| 383 | + total += 1 |
| 384 | + if max_samples is not None and total >= max_samples: |
| 385 | + break |
400 | 386 |
|
401 | 387 | if counts["train"] == 0: |
402 | 388 | raise RuntimeError("No training samples were written.") |
|
0 commit comments