File size: 28,066 Bytes
5ed07ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
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
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
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
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
270
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
332
333
334
335
336
337
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
364
365
366
367
368
369
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
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
"""
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}")