multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
5ed07ee verified
Raw
History Blame Contribute Delete
28.1 kB
"""
Arrow dataset writing for SVS preprocessing.
Provides :func:`process_and_save` (single-GPU) and
:func:`process_and_save_multigpu` (multi-GPU via a dynamic work queue) for
converting raw SVS samples into Arrow format suitable for training.
Multi-GPU dispatch is **dynamic**: the parent feeds duration-homogeneous chunks
into a bounded ``mp.Queue`` and each GPU worker pulls chunks on demand, so a
fast/uncontended card processes more work than a slow/shared one and the total
wall-clock is bounded by aggregate throughput rather than the slowest card.
"""
import json
import threading
import time
import queue as _queue
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Dict, List, Optional
import torch
from tqdm import tqdm
from .svs_preprocessor import SVSPreprocessor
from .text_tensor import build_text_tensor, estimate_duration_from_notes
# ---------------------------------------------------------------------------
# Shared Arrow helpers (module level so both the single-GPU path and the
# multi-GPU worker loop reuse the exact same schema / writer / encode loop).
# ---------------------------------------------------------------------------
def _build_features():
"""HuggingFace ``Features`` describing one preprocessed SVS sample.
``audio_feats`` is stored as float16 (native arrow halffloat, 2 bytes) to
halve on-disk size (~460G f32 -> ~230G). Loaders cast to f32/bf16 on read,
and the extra error is bounded by 1 bf16 ULP (training runs amp_bf16), i.e.
below the bf16 noise floor the flow-matching target already incurs.
"""
from datasets import Features, Sequence, Value
return Features({
"packed_text_tokens": Sequence(Value("int32")),
"audio_feats": Sequence(Sequence(Sequence(Value("float16")))), # [T, P, D]
"text_mask": Sequence(Value("int32")),
"audio_mask": Sequence(Value("int32")),
"loss_mask": Sequence(Value("int32")),
"labels": Sequence(Value("int32")),
"audio_duration": Value("float64"),
"estimated_duration": Value("float64"), # Pre-computed for inference sorting
"text_token_count": Value("int64"),
"total_length": Value("int64"),
"item_name": Value("string"),
"song_name": Value("string"), # Song identifier for validation splitting
"song_folder": Value("string"),
"dataset_name": Value("string"), # Source dataset identifier
# Structured metadata -- stored verbatim from source annotations
# so that downstream tasks (score rendering, analysis) can use them
# without reverse-parsing the SVS token sequence.
"svs_prompt": Value("string"), # Decoded SVS prompt text
"bpm": Value("int64"),
"word": Sequence(Value("string")), # e.g. ["让", "我", "AP", ...]
"pitch": Sequence(Value("int64")), # MIDI pitch per note, e.g. [60, 58, 0, ...]
"pitch_dur": Sequence(Value("float64")), # Duration in seconds per pitch
"note": Sequence(Value("string")), # Note token strings, e.g. ["<NOTE_4>", ...]
"pitch2word": Sequence(Value("int64")), # Pitch-to-word alignment index
"word_dur": Sequence(Value("float64")), # Word-level durations in seconds
"has_score": Value("bool"), # True = full annotation, False = word-only weak label
})
def _create_empty_batch():
"""Create an empty batch dictionary (one key per feature column)."""
return {
"packed_text_tokens": [],
"audio_feats": [],
"text_mask": [],
"audio_mask": [],
"loss_mask": [],
"labels": [],
"audio_duration": [],
"estimated_duration": [],
"text_token_count": [],
"total_length": [],
"item_name": [],
"song_name": [],
"song_folder": [],
"dataset_name": [],
"svs_prompt": [],
"bpm": [],
"word": [],
"pitch": [],
"pitch_dur": [],
"note": [],
"pitch2word": [],
"word_dur": [],
"has_score": [],
}
def _to_arrow_array(values, arrow_type):
"""Convert a list of values to a PyArrow array matching the given type.
Multi-dimensional numpy arrays (e.g. audio_feats [T,P,D]) must be converted
to nested Python lists first, because pa.array() only accepts 1-D arrays.
"""
import numpy as np
import pyarrow as pa
converted = [
v.tolist() if isinstance(v, np.ndarray) and v.ndim > 1 else v
for v in values
]
return pa.array(converted, type=arrow_type)
class _ShardWriter:
"""Manages incremental Arrow IPC stream writing with shard rotation.
A single writer can be fed many batches over its lifetime (across multiple
work-queue chunks in the multi-GPU path); shards roll once they reach
``shard_size`` samples. ``rank_prefix`` keeps filenames unique per worker.
"""
def __init__(self, output_path: Path, shard_size: int, arrow_schema,
rank_prefix: str = ""):
self.output_path = output_path
self.shard_size = shard_size
self.arrow_schema = arrow_schema
self.rank_prefix = rank_prefix
self.shard_idx = 0
self.shard_sample_count = 0
self.shard_paths: List[str] = []
self._file = None
self._writer = None
self._open_new_shard()
def _open_new_shard(self):
import pyarrow as pa
shard_filename = f"data-{self.rank_prefix}{self.shard_idx:05d}.arrow"
shard_path = self.output_path / shard_filename
self.shard_paths.append(str(shard_path))
self._file = pa.OSFile(str(shard_path), 'wb')
self._writer = pa.ipc.new_stream(self._file, self.arrow_schema)
def write_batch(self, batch_dict: Dict, n_samples: int):
"""Write a small batch dict as an Arrow RecordBatch.
Handles shard rotation: if adding ``n_samples`` exceeds ``shard_size``,
close the current shard and start a new one first.
"""
import pyarrow as pa
if n_samples == 0:
return
if self.shard_sample_count > 0 and self.shard_sample_count + n_samples > self.shard_size:
self._close_current_shard()
self.shard_idx += 1
self._open_new_shard()
arrays = [
_to_arrow_array(batch_dict[field.name], field.type)
for field in self.arrow_schema
]
rb = pa.RecordBatch.from_arrays(arrays, schema=self.arrow_schema)
self._writer.write_batch(rb)
self.shard_sample_count += n_samples
def _close_current_shard(self):
if self._writer is not None:
self._writer.close()
self._writer = None
if self._file is not None:
self._file.close()
self._file = None
print(f" Wrote shard {self.rank_prefix}{self.shard_idx}: {self.shard_sample_count} samples")
self.shard_sample_count = 0
@property
def num_shards(self) -> int:
"""Total shards opened (1-based count)."""
return self.shard_idx + 1
def close(self):
"""Close everything; call once at the end."""
self._close_current_shard()
def _estimate_sort_duration(s):
"""Estimate a sample's audio duration for length-based sorting.
Full-score samples use the note schedule; weak-label samples fall back to
the audio file header (fast, no full decode).
"""
import soundfile as sf
if s["has_score"] and s["notes"]:
return estimate_duration_from_notes(s["notes"], s["bpm"])
try:
info = sf.info(s["audio_path"])
return info.duration
except Exception:
return 0.0
# Timing buckets used by the encode loop (and the single-GPU report).
_TIMING_KEYS = [
"cpu_prep", # audio_load + resample + build_text (threaded)
"vae_encode", # batch VAE encode (GPU)
"assemble", # process_sample + decode_prompt + batch_accum
"shard_write", # incremental Arrow RecordBatch writes
]
def _encode_into_shards(
samples: List[Dict],
preprocessor: SVSPreprocessor,
shard_writer: _ShardWriter,
num_workers: int,
vae_batch_size: int,
vae_max_tokens: int,
pbar=None,
timing_totals: Optional[Dict] = None,
timing_counts: Optional[Dict] = None,
):
"""Encode ``samples`` and append them into the (already-open) shard_writer.
Runs a thread-pool prefetch (overlaps audio I/O with GPU) plus dynamic
VAE-batch sizing. Does NOT sort ``samples`` (caller guarantees ordering)
and does NOT close ``shard_writer`` (so it can be reused across chunks).
Returns ``(n_processed, n_skipped)``.
"""
import torchaudio
import soundfile as sf
if timing_totals is None:
timing_totals = {k: 0.0 for k in _TIMING_KEYS}
if timing_counts is None:
timing_counts = {k: 0 for k in _TIMING_KEYS}
n_processed = 0
n_skipped = 0
_target_sr = preprocessor.sample_rate
_tokenizer = preprocessor.tokenizer
# -- Helper: prepare a single sample (runs in thread pool) ---------
def _prepare_one(sample: Dict) -> Optional[Dict]:
"""Thread-safe single-sample preparation (CPU/IO only)."""
try:
audio_data, sr = sf.read(sample["audio_path"])
waveform = torch.from_numpy(audio_data).float()
if waveform.dim() == 1:
waveform = waveform.unsqueeze(0)
else:
waveform = waveform.T
if sr != _target_sr:
resampler = torchaudio.transforms.Resample(sr, _target_sr)
waveform = resampler(waveform)
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
waveform = waveform.squeeze(0) # [T]
estimated_duration = estimate_duration_from_notes(
sample["notes"], sample["bpm"],
) if sample["has_score"] and sample["notes"] else (
waveform.shape[-1] / _target_sr # Weak label: use actual audio duration
)
text_tensor = build_text_tensor(
sample["syllables"], sample["bpm"], _tokenizer,
has_score=sample["has_score"],
)
if text_tensor.shape[0] == 0:
return None
return {
"sample": sample,
"waveform": waveform,
"text_tensor": text_tensor,
"estimated_duration": estimated_duration,
}
except Exception as e:
print(f"Error loading {sample.get('audio_path', '?')}: {e}")
return None
# -- Helper: encode a mini-batch and assemble results ---------------
def _encode_and_assemble(mini_batch: List[Dict]):
"""Run batch VAE encode + write results incrementally to Arrow."""
nonlocal n_processed, n_skipped
# Phase 2: Batch VAE encode
t_vae_start = time.perf_counter()
waveforms = [item["waveform"] for item in mini_batch]
audio_results = preprocessor.encode_audio_batch(waveforms)
t_vae_end = time.perf_counter()
timing_totals["vae_encode"] += t_vae_end - t_vae_start
timing_counts["vae_encode"] += len(mini_batch)
# Phase 3: Assemble into a small batch dict, then write immediately
t_asm_start = time.perf_counter()
batch = _create_empty_batch()
n_ok = 0
for item, audio_pair in zip(mini_batch, audio_results):
try:
sample = item["sample"]
has_score = sample.get("has_score", True)
result = preprocessor.process_sample(
text_tensor=item["text_tensor"],
is_prompt=False,
precomputed_audio=audio_pair,
has_score=has_score,
)
svs_seq_ids = result["packed_text_tokens"].numpy()
text_mask_np = result["text_mask"].numpy()
text_token_ids = svs_seq_ids[text_mask_np == 1].tolist()
if text_token_ids:
text_token_ids = text_token_ids[:-1]
svs_prompt = preprocessor.tokenizer.decode(text_token_ids, skip_special_tokens=False)
batch["packed_text_tokens"].append(result["packed_text_tokens"].numpy().astype('int32'))
batch["audio_feats"].append(result["audio_feats"].numpy().astype('float16'))
batch["text_mask"].append(result["text_mask"].numpy().astype('int32'))
batch["audio_mask"].append(result["audio_mask"].numpy().astype('int32'))
batch["loss_mask"].append(result["loss_mask"].numpy().astype('int32'))
batch["labels"].append(result["labels"].numpy().astype('int32'))
batch["audio_duration"].append(float(result["audio_duration"]))
batch["estimated_duration"].append(float(item["estimated_duration"]))
batch["text_token_count"].append(int(result["text_token_count"]))
batch["total_length"].append(int(result["total_length"]))
batch["item_name"].append(sample["item_name"])
batch["song_name"].append(sample.get("song_name", ""))
batch["song_folder"].append(sample["song_folder"])
batch["dataset_name"].append(sample.get("dataset_name", "unknown"))
batch["svs_prompt"].append(svs_prompt)
batch["bpm"].append(int(sample["bpm"]))
batch["word"].append(sample.get("word", []))
batch["pitch"].append([int(p) for p in sample.get("pitch", [])])
batch["pitch_dur"].append([float(d) for d in sample.get("pitch_dur", [])])
batch["note"].append(sample.get("note", sample.get("notes", [])))
batch["pitch2word"].append([int(p) for p in sample.get("pitch2word", [])])
batch["word_dur"].append([float(d) for d in sample.get("word_dur", [])])
batch["has_score"].append(has_score)
n_ok += 1
n_processed += 1
except Exception as e:
print(f"Error assembling {item['sample']['audio_path']}: {e}")
n_skipped += 1
continue
t_asm_end = time.perf_counter()
timing_totals["assemble"] += t_asm_end - t_asm_start
timing_counts["assemble"] += len(mini_batch)
# Incremental Arrow write (tiny batch -> negligible overhead)
if n_ok > 0:
t0 = time.perf_counter()
shard_writer.write_batch(batch, n_ok)
t1 = time.perf_counter()
timing_totals["shard_write"] += t1 - t0
timing_counts["shard_write"] += n_ok
# ==================================================================
# Main loop: ThreadPool prefetch + dynamic batch sizing
# ==================================================================
PREFETCH = max(num_workers * 8, vae_batch_size * 4)
with ThreadPoolExecutor(max_workers=num_workers) as pool:
# Sliding window of futures -- bounded memory usage
future_queue = deque()
submit_idx = 0
# Seed the prefetch window
while submit_idx < min(PREFETCH, len(samples)):
future_queue.append(pool.submit(_prepare_one, samples[submit_idx]))
submit_idx += 1
mini_batch = []
batch_tokens = 0
while future_queue:
# Wait for next result (in order)
t_cpu_start = time.perf_counter()
item = future_queue.popleft().result()
if pbar is not None:
pbar.update(1)
# Keep prefetch window full
if submit_idx < len(samples):
future_queue.append(pool.submit(_prepare_one, samples[submit_idx]))
submit_idx += 1
t_cpu_end = time.perf_counter()
if item is None:
n_skipped += 1
continue
timing_totals["cpu_prep"] += t_cpu_end - t_cpu_start
timing_counts["cpu_prep"] += 1
wav_len = item["waveform"].size(-1)
# Dynamic batch sizing: flush if adding this sample would exceed limits
if mini_batch and (batch_tokens + wav_len > vae_max_tokens
or len(mini_batch) >= vae_batch_size):
_encode_and_assemble(mini_batch)
mini_batch = []
batch_tokens = 0
mini_batch.append(item)
batch_tokens += wav_len
# Flush remaining mini-batch
if mini_batch:
_encode_and_assemble(mini_batch)
return n_processed, n_skipped
def _print_timing_report(total_processed, wall_total, timing_totals, timing_counts, tag=""):
print(f"\n{'='*70}")
print(f" FINAL Timing Report{tag} ({total_processed} samples, wall: {wall_total:.1f}s)")
print(f"{'='*70}")
print(f" {'Step':<16s} {'Total (s)':>10s} {'Avg (ms)':>10s} {'% Wall':>8s} {'Count':>6s}")
print(f" {'-'*16} {'-'*10} {'-'*10} {'-'*8} {'-'*6}")
for k in _TIMING_KEYS:
tot = timing_totals[k]
cnt = timing_counts[k]
avg_ms = (tot / cnt * 1000) if cnt > 0 else 0
pct = (tot / wall_total * 100) if wall_total > 0 else 0
print(f" {k:<16s} {tot:>10.2f} {avg_ms:>10.2f} {pct:>7.1f}% {cnt:>6d}")
accounted = sum(timing_totals.values())
overhead = wall_total - accounted
if wall_total > 0:
print(f" {'overhead':<16s} {overhead:>10.2f} {'':>10s} {overhead/wall_total*100:>7.1f}%")
print(f" {'TOTAL WALL':<16s} {wall_total:>10.2f}")
if wall_total > 0:
print(f" Throughput: {total_processed/wall_total:.1f} samples/sec")
print(f"{'='*70}")
def _write_dataset_metadata(output_path: Path, num_rows: int, shard_paths: List[str]):
"""Write HuggingFace-compatible dataset_info.json / state.json."""
features = _build_features()
dataset_info = {
"description": "SVS preprocessed dataset",
"features": features.to_dict(),
"num_rows": num_rows,
"num_shards": len(shard_paths),
}
with open(output_path / "dataset_info.json", "w") as f:
json.dump(dataset_info, f, indent=2)
state = {
"_data_files": [{"filename": Path(p).name} for p in shard_paths],
"_fingerprint": None,
"_format_columns": None,
"_format_kwargs": {},
"_format_type": None,
"_output_all_columns": False,
"_split": None,
}
with open(output_path / "state.json", "w") as f:
json.dump(state, f, indent=2)
def process_and_save(
samples: List[Dict],
preprocessor: SVSPreprocessor,
output_path: Path,
num_workers: int = 4,
shard_size: int = 1000,
vae_batch_size: int = 32,
vae_max_tokens: int = 44100 * 100,
rank_prefix: str = "",
):
"""
Process samples and save to Arrow format using chunked/streaming writes.
Uses ThreadPoolExecutor for async audio prefetching (overlaps I/O with GPU)
and dynamic batch sizing based on total audio tokens (prevents OOM for
long sequences while maximising GPU utilisation for short ones).
Args:
samples: List of raw sample dictionaries
preprocessor: SVSPreprocessor instance
output_path: Path to save the Arrow dataset
num_workers: Number of workers for parallel audio loading
shard_size: Number of samples per shard (default 1000)
vae_batch_size: Hard cap on max samples per VAE batch (default 32)
vae_max_tokens: Max total waveform samples per VAE batch (dynamic sizing)
rank_prefix: Optional prefix for shard filenames (for multi-GPU)
"""
output_path.mkdir(parents=True, exist_ok=True)
features = _build_features()
shard_writer = _ShardWriter(output_path, shard_size, features.arrow_schema, rank_prefix)
# Sort by estimated audio duration so consecutive mini-batches contain
# similar-length waveforms, minimising padding waste.
samples.sort(key=_estimate_sort_duration)
durations = [_estimate_sort_duration(s) for s in [samples[0], samples[-1]]]
print(f"Sorted samples by estimated duration: {durations[0]:.2f}s - {durations[1]:.2f}s")
print(f"Processing {len(samples)} samples with shard_size={shard_size}, "
f"vae_batch_size(max)={vae_batch_size}, vae_max_tokens={vae_max_tokens}")
timing_totals = {k: 0.0 for k in _TIMING_KEYS}
timing_counts = {k: 0 for k in _TIMING_KEYS}
wall_start = time.perf_counter()
pbar = tqdm(total=len(samples), desc="Processing samples")
total_processed, skipped = _encode_into_shards(
samples, preprocessor, shard_writer,
num_workers=num_workers,
vae_batch_size=vae_batch_size,
vae_max_tokens=vae_max_tokens,
pbar=pbar,
timing_totals=timing_totals,
timing_counts=timing_counts,
)
pbar.close()
shard_writer.close()
shard_paths = shard_writer.shard_paths
wall_total = time.perf_counter() - wall_start
print(f"\nProcessed {total_processed} samples across {shard_writer.num_shards} shards, skipped {skipped}")
_print_timing_report(total_processed, wall_total, timing_totals, timing_counts)
if total_processed == 0:
print("No samples processed!")
return
_write_dataset_metadata(output_path, total_processed, shard_paths)
print(f"Saved to {output_path}")
def _gpu_worker_loop(
rank: int,
world_size: int,
task_queue,
pretrained_path: str,
sample_rate: int,
output_path: Path,
num_workers: int,
shard_size: int,
vae_batch_size: int,
vae_max_tokens: int,
):
"""Dynamic multi-GPU worker: pull chunks off ``task_queue`` until a ``None``
sentinel, encoding each into this rank's own rolling shards.
Fast cards drain more chunks than slow/contended ones, so the work
self-balances. Metadata is written by the parent (this worker only writes
``data-r{rank}-*.arrow`` shards).
"""
device = f"cuda:{rank}"
print(f"[GPU {rank}] worker starting on {device}")
preprocessor = SVSPreprocessor(
pretrained_path=pretrained_path,
sample_rate=sample_rate,
device=device,
)
features = _build_features()
shard_writer = _ShardWriter(output_path, shard_size, features.arrow_schema,
rank_prefix=f"r{rank}-")
timing_totals = {k: 0.0 for k in _TIMING_KEYS}
timing_counts = {k: 0 for k in _TIMING_KEYS}
wall_start = time.perf_counter()
pbar = tqdm(desc=f"[GPU {rank}]", position=rank)
total_processed = 0
skipped = 0
chunks_done = 0
per_rank_workers = max(1, num_workers // world_size)
while True:
chunk = task_queue.get()
if chunk is None: # sentinel: no more work
break
n_proc, n_skip = _encode_into_shards(
chunk, preprocessor, shard_writer,
num_workers=per_rank_workers,
vae_batch_size=vae_batch_size,
vae_max_tokens=vae_max_tokens,
pbar=pbar,
timing_totals=timing_totals,
timing_counts=timing_counts,
)
total_processed += n_proc
skipped += n_skip
chunks_done += 1
pbar.close()
shard_writer.close()
wall_total = time.perf_counter() - wall_start
print(f"[GPU {rank}] Done: {total_processed} samples, {chunks_done} chunks, "
f"{shard_writer.num_shards} shards, skipped {skipped}")
_print_timing_report(total_processed, wall_total, timing_totals, timing_counts,
tag=f" [GPU {rank}]")
def process_and_save_multigpu(
samples: List[Dict],
params: Dict,
output_path: Path,
num_gpus: int,
):
"""
Multi-GPU parallel preprocessing via a dynamic work queue.
The parent sorts samples globally by duration, splits them into
duration-homogeneous chunks, and feeds them into a bounded ``mp.Queue``.
``num_gpus`` workers each pull chunks on demand (one VAE per GPU) and write
rank-prefixed shards into ``output_path``. After all workers finish, the
parent generates the dataset metadata. Because dispatch is dynamic, a
fast/uncontended GPU processes more chunks than a slow/shared one, so the
wall-clock is bounded by aggregate throughput rather than the slowest card.
"""
import torch.multiprocessing as mp
output_path.mkdir(parents=True, exist_ok=True)
print(f"\nMulti-GPU processing (dynamic dispatch): {num_gpus} GPUs, {len(samples)} samples")
# Global duration sort so each contiguous chunk is length-homogeneous
# (keeps per-VAE-batch padding tight inside every worker).
samples.sort(key=_estimate_sort_duration)
durations = [_estimate_sort_duration(s) for s in [samples[0], samples[-1]]]
print(f" Sorted by estimated duration: {durations[0]:.2f}s - {durations[1]:.2f}s")
chunk_size = int(params.get("dispatch_chunk_size", 2000))
chunks = [samples[i:i + chunk_size] for i in range(0, len(samples), chunk_size)]
print(f" {len(chunks)} chunks of up to {chunk_size} samples; queue maxsize={num_gpus * 3}")
ctx = mp.get_context("spawn")
task_queue = ctx.Queue(maxsize=num_gpus * 3)
procs = []
for rank in range(num_gpus):
p = ctx.Process(
target=_gpu_worker_loop,
args=(
rank,
num_gpus,
task_queue,
params["pretrained_path"],
params["sample_rate"],
output_path,
params["num_workers"],
params["shard_size"],
params["vae_batch_size"],
params["vae_max_tokens"],
),
)
p.start()
procs.append(p)
# Feeder thread: push chunks (blocking on maxsize) then one sentinel per
# worker. Aborts early if a worker has already died so it never blocks
# forever against a full queue with no live consumer.
def _worker_died():
return any(p.exitcode is not None and p.exitcode != 0 for p in procs)
def _feed():
for c in chunks:
while True:
if _worker_died():
return
try:
task_queue.put(c, timeout=5)
break
except _queue.Full:
continue
for _ in range(num_gpus):
while True:
if _worker_died():
return
try:
task_queue.put(None, timeout=5)
break
except _queue.Full:
continue
feeder = threading.Thread(target=_feed, name="chunk-feeder", daemon=True)
feeder.start()
# Wait for workers; if any crashes, terminate the rest so blocked
# ``queue.get()`` calls don't deadlock the join.
while any(p.is_alive() for p in procs):
if _worker_died():
for p in procs:
if p.is_alive():
p.terminate()
break
time.sleep(2)
for p in procs:
p.join()
feeder.join(timeout=10)
failed = [(rank, p.exitcode) for rank, p in enumerate(procs) if p.exitcode != 0]
if failed:
raise RuntimeError(f"Multi-GPU worker(s) failed (rank, exitcode): {failed}")
# After all workers finish, generate dataset metadata from the shards.
import pyarrow as pa
shard_files = sorted(output_path.glob("data-*.arrow"))
shard_paths = [str(p) for p in shard_files]
total_samples = 0
for sp in shard_files:
with pa.OSFile(str(sp), 'rb') as f:
reader = pa.ipc.open_stream(f)
table = reader.read_all()
total_samples += len(table)
print(f"\nAll GPUs done. {total_samples} samples across {len(shard_paths)} shards.")
_write_dataset_metadata(output_path, total_samples, shard_paths)
print(f"Saved to {output_path}")