marcos Claude Opus 4.5 commited on
Commit
e20f447
·
1 Parent(s): 5cc96de

feat: Refactor training with SOLID principles and add optimizations

Browse files

Training refactor:
- Modular training package (config, data, models, trainer, checkpoint)
- Auto GPU detection (H200 141GB, A100, RTX 4090)
- Flash Attention 2 with fallback to SDPA
- OOM recovery with proper cleanup (PyTorch best practices)
- NaN/Inf detection and gradient monitoring
- Sequence length bucketing for memory efficiency
- BF16 for numerical stability

Stage 1 improvements:
- Added --no_decay flag (fixed text_ratio for adapter warmup)
- Dynamic decay optional via --use_decay

Dataset tools:
- analyze_dataset.py for dataset inspection
- validate_dataset.py for format validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

datasets/analyze_dataset.py ADDED
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Deep Dataset Analyzer - Comprehensive analysis of generated dataset.
4
+
5
+ Analyzes:
6
+ 1. Structure and field presence
7
+ 2. Whisper features (shape, statistics, quality)
8
+ 3. SNAC tokens (offsets, distribution, frame integrity)
9
+ 4. Text content (questions, answers, tokenization)
10
+ 5. Word alignments (timing, coverage, token mapping)
11
+ 6. Cross-field consistency
12
+ 7. Training readiness checks
13
+ """
14
+
15
+ import argparse
16
+ import sys
17
+ from pathlib import Path
18
+ from collections import defaultdict, Counter
19
+ import json
20
+
21
+ import torch
22
+ import numpy as np
23
+
24
+ # SNAC constants
25
+ SNAC_BASE = 128266
26
+ SNAC_LAYERS = 7
27
+ SNAC_VOCAB_PER_LAYER = 4096
28
+ WHISPER_DIM = 1280
29
+ EOS_TOKEN = 128009
30
+
31
+
32
+ def print_header(title):
33
+ print(f"\n{'='*70}")
34
+ print(f" {title}")
35
+ print('='*70)
36
+
37
+
38
+ def print_subheader(title):
39
+ print(f"\n{'-'*50}")
40
+ print(f" {title}")
41
+ print('-'*50)
42
+
43
+
44
+ def analyze_whisper_features(dataset):
45
+ """Deep analysis of Whisper features."""
46
+ print_header("WHISPER FEATURES ANALYSIS")
47
+
48
+ lengths = []
49
+ dims = []
50
+ min_vals = []
51
+ max_vals = []
52
+ mean_vals = []
53
+ std_vals = []
54
+ has_nan = 0
55
+ has_inf = 0
56
+ dtypes = Counter()
57
+
58
+ for i, sample in enumerate(dataset):
59
+ if "whisper_features" not in sample:
60
+ continue
61
+
62
+ wf = sample["whisper_features"]
63
+
64
+ if not isinstance(wf, torch.Tensor):
65
+ print(f" [WARN] Sample {i}: whisper_features is {type(wf).__name__}, not Tensor")
66
+ continue
67
+
68
+ dtypes[str(wf.dtype)] += 1
69
+ lengths.append(wf.shape[0])
70
+
71
+ if wf.dim() >= 2:
72
+ dims.append(wf.shape[1])
73
+
74
+ if torch.isnan(wf).any():
75
+ has_nan += 1
76
+ if torch.isinf(wf).any():
77
+ has_inf += 1
78
+
79
+ min_vals.append(wf.min().item())
80
+ max_vals.append(wf.max().item())
81
+ mean_vals.append(wf.mean().item())
82
+ std_vals.append(wf.std().item())
83
+
84
+ if not lengths:
85
+ print(" No whisper_features found!")
86
+ return
87
+
88
+ lengths = np.array(lengths)
89
+
90
+ print(f"\n Samples with whisper_features: {len(lengths)}/{len(dataset)}")
91
+ print(f" Data types: {dict(dtypes)}")
92
+
93
+ print_subheader("Sequence Length")
94
+ print(f" Min: {lengths.min()}, Max: {lengths.max()}")
95
+ print(f" Mean: {lengths.mean():.1f}, Std: {lengths.std():.1f}")
96
+ print(f" Median: {np.median(lengths):.1f}")
97
+
98
+ # Length distribution
99
+ percentiles = [10, 25, 50, 75, 90, 95, 99]
100
+ print(f" Percentiles: {dict(zip(percentiles, [int(np.percentile(lengths, p)) for p in percentiles]))}")
101
+
102
+ if dims:
103
+ dims = np.array(dims)
104
+ unique_dims = np.unique(dims)
105
+ print(f"\n Feature dimension: {unique_dims}")
106
+ if len(unique_dims) == 1 and unique_dims[0] == WHISPER_DIM:
107
+ print(f" ✅ Correct Whisper dimension ({WHISPER_DIM})")
108
+ else:
109
+ print(f" ⚠️ Expected dimension {WHISPER_DIM}")
110
+
111
+ print_subheader("Value Statistics")
112
+ print(f" Min value: {np.min(min_vals):.4f}")
113
+ print(f" Max value: {np.max(max_vals):.4f}")
114
+ print(f" Mean of means: {np.mean(mean_vals):.4f}")
115
+ print(f" Mean of stds: {np.mean(std_vals):.4f}")
116
+
117
+ print_subheader("Quality Checks")
118
+ print(f" Samples with NaN: {has_nan} {'⚠️' if has_nan > 0 else '✅'}")
119
+ print(f" Samples with Inf: {has_inf} {'⚠️' if has_inf > 0 else '✅'}")
120
+
121
+ # Check for constant/zero features
122
+ zero_features = sum(1 for s in std_vals if s < 0.001)
123
+ print(f" Samples with near-zero std: {zero_features} {'⚠️' if zero_features > 0 else '✅'}")
124
+
125
+
126
+ def analyze_snac_tokens(dataset):
127
+ """Deep analysis of SNAC tokens."""
128
+ print_header("SNAC TOKENS ANALYSIS")
129
+
130
+ lengths = []
131
+ frame_counts = []
132
+ token_values = []
133
+ offset_distribution = defaultdict(int)
134
+ raw_token_distribution = defaultdict(int)
135
+ incomplete_frames = 0
136
+ has_offset = 0
137
+ no_offset = 0
138
+ dtypes = Counter()
139
+
140
+ for i, sample in enumerate(dataset):
141
+ if "snac_tokens" not in sample:
142
+ continue
143
+
144
+ st = sample["snac_tokens"]
145
+
146
+ if not isinstance(st, torch.Tensor):
147
+ print(f" [WARN] Sample {i}: snac_tokens is {type(st).__name__}, not Tensor")
148
+ continue
149
+
150
+ dtypes[str(st.dtype)] += 1
151
+ length = len(st)
152
+ lengths.append(length)
153
+ frame_counts.append(length // SNAC_LAYERS)
154
+
155
+ if length % SNAC_LAYERS != 0:
156
+ incomplete_frames += 1
157
+
158
+ # Analyze token values
159
+ for j, tok in enumerate(st.tolist()):
160
+ token_values.append(tok)
161
+
162
+ if tok >= SNAC_BASE:
163
+ has_offset += 1
164
+ # Determine which position offset
165
+ offset_idx = (tok - SNAC_BASE) // SNAC_VOCAB_PER_LAYER
166
+ offset_distribution[offset_idx] += 1
167
+ # Get raw token value
168
+ raw_tok = (tok - SNAC_BASE) % SNAC_VOCAB_PER_LAYER
169
+ raw_token_distribution[raw_tok] += 1
170
+ else:
171
+ no_offset += 1
172
+
173
+ if not lengths:
174
+ print(" No snac_tokens found!")
175
+ return
176
+
177
+ lengths = np.array(lengths)
178
+ frame_counts = np.array(frame_counts)
179
+ token_values = np.array(token_values)
180
+
181
+ print(f"\n Samples with snac_tokens: {len(lengths)}/{len(dataset)}")
182
+ print(f" Data types: {dict(dtypes)}")
183
+
184
+ print_subheader("Token Counts")
185
+ print(f" Total tokens: {len(token_values):,}")
186
+ print(f" Min per sample: {lengths.min()}, Max: {lengths.max()}")
187
+ print(f" Mean: {lengths.mean():.1f}, Std: {lengths.std():.1f}")
188
+
189
+ print_subheader("Frame Analysis (7 tokens per frame)")
190
+ print(f" Min frames: {frame_counts.min()}, Max: {frame_counts.max()}")
191
+ print(f" Mean frames: {frame_counts.mean():.1f}")
192
+ print(f" Incomplete frames (not multiple of 7): {incomplete_frames} {'⚠️' if incomplete_frames > 0 else '✅'}")
193
+
194
+ # Duration estimation (75 frames/second for SNAC 24kHz)
195
+ durations = frame_counts / 75.0
196
+ print(f"\n Audio duration (estimated):")
197
+ print(f" Min: {durations.min():.2f}s, Max: {durations.max():.2f}s")
198
+ print(f" Mean: {durations.mean():.2f}s, Total: {durations.sum()/60:.1f} min")
199
+
200
+ print_subheader("Token Offset Analysis")
201
+ print(f" Tokens with offset (>= {SNAC_BASE}): {has_offset:,} ({100*has_offset/len(token_values):.1f}%)")
202
+ print(f" Tokens without offset: {no_offset:,} ({100*no_offset/len(token_values):.1f}%)")
203
+
204
+ if has_offset > 0:
205
+ print(f"\n Offset distribution by position (0-6):")
206
+ for pos in range(SNAC_LAYERS):
207
+ count = offset_distribution.get(pos, 0)
208
+ expected_pct = 100 / SNAC_LAYERS
209
+ actual_pct = 100 * count / has_offset if has_offset > 0 else 0
210
+ status = "✅" if abs(actual_pct - expected_pct) < 5 else "⚠️"
211
+ print(f" Position {pos}: {count:,} ({actual_pct:.1f}%) {status}")
212
+
213
+ print_subheader("Token Value Range")
214
+ print(f" Min token: {token_values.min()}")
215
+ print(f" Max token: {token_values.max()}")
216
+
217
+ if has_offset > 0:
218
+ expected_min = SNAC_BASE
219
+ expected_max = SNAC_BASE + (SNAC_LAYERS * SNAC_VOCAB_PER_LAYER) - 1
220
+ print(f" Expected range: [{expected_min}, {expected_max}]")
221
+ if token_values.min() >= expected_min and token_values.max() <= expected_max:
222
+ print(f" ✅ Token range valid")
223
+ else:
224
+ print(f" ⚠️ Some tokens outside expected range")
225
+
226
+
227
+ def analyze_text_content(dataset):
228
+ """Analyze text fields (question, answer, text_tokens)."""
229
+ print_header("TEXT CONTENT ANALYSIS")
230
+
231
+ questions = []
232
+ answers = []
233
+ text_token_lengths = []
234
+ has_text = 0
235
+ has_answer = 0
236
+ has_text_tokens = 0
237
+
238
+ for sample in dataset:
239
+ if "text" in sample and sample["text"]:
240
+ has_text += 1
241
+ questions.append(sample["text"])
242
+
243
+ if "answer" in sample and sample["answer"]:
244
+ has_answer += 1
245
+ answers.append(sample["answer"])
246
+
247
+ if "text_tokens" in sample:
248
+ has_text_tokens += 1
249
+ tt = sample["text_tokens"]
250
+ if isinstance(tt, torch.Tensor):
251
+ text_token_lengths.append(len(tt))
252
+ elif isinstance(tt, list):
253
+ text_token_lengths.append(len(tt))
254
+
255
+ print(f"\n Field presence:")
256
+ print(f" text (question): {has_text}/{len(dataset)} ({100*has_text/len(dataset):.1f}%)")
257
+ print(f" answer: {has_answer}/{len(dataset)} ({100*has_answer/len(dataset):.1f}%)")
258
+ print(f" text_tokens: {has_text_tokens}/{len(dataset)} ({100*has_text_tokens/len(dataset):.1f}%)")
259
+
260
+ if questions:
261
+ print_subheader("Questions Analysis")
262
+ q_lengths = [len(q) for q in questions]
263
+ q_words = [len(q.split()) for q in questions]
264
+ print(f" Character length: min={min(q_lengths)}, max={max(q_lengths)}, mean={np.mean(q_lengths):.1f}")
265
+ print(f" Word count: min={min(q_words)}, max={max(q_words)}, mean={np.mean(q_words):.1f}")
266
+
267
+ # Sample questions
268
+ print(f"\n Sample questions:")
269
+ for q in questions[:3]:
270
+ print(f" - {q[:80]}{'...' if len(q) > 80 else ''}")
271
+
272
+ if answers:
273
+ print_subheader("Answers Analysis")
274
+ a_lengths = [len(a) for a in answers]
275
+ a_words = [len(a.split()) for a in answers]
276
+ print(f" Character length: min={min(a_lengths)}, max={max(a_lengths)}, mean={np.mean(a_lengths):.1f}")
277
+ print(f" Word count: min={min(a_words)}, max={max(a_words)}, mean={np.mean(a_words):.1f}")
278
+
279
+ # Sample answers
280
+ print(f"\n Sample answers:")
281
+ for a in answers[:3]:
282
+ print(f" - {a[:100]}{'...' if len(a) > 100 else ''}")
283
+
284
+ if text_token_lengths:
285
+ print_subheader("Pre-tokenized Answer Tokens")
286
+ tl = np.array(text_token_lengths)
287
+ print(f" Token count: min={tl.min()}, max={tl.max()}, mean={tl.mean():.1f}")
288
+
289
+
290
+ def analyze_word_alignments(dataset):
291
+ """Analyze word alignments for IST-LM interleaving."""
292
+ print_header("WORD ALIGNMENTS ANALYSIS")
293
+
294
+ has_alignments = 0
295
+ word_counts = []
296
+ frame_coverages = []
297
+ has_tokens = 0
298
+ token_counts = []
299
+ timing_issues = 0
300
+
301
+ for i, sample in enumerate(dataset):
302
+ if "word_alignments" not in sample or not sample["word_alignments"]:
303
+ continue
304
+
305
+ has_alignments += 1
306
+ wa = sample["word_alignments"]
307
+ word_counts.append(len(wa))
308
+
309
+ # Check alignment quality
310
+ last_end = 0
311
+ sample_has_tokens = True
312
+ sample_token_count = 0
313
+
314
+ for j, alignment in enumerate(wa):
315
+ # Check required fields
316
+ if 'word' not in alignment or 'start_frame' not in alignment or 'end_frame' not in alignment:
317
+ continue
318
+
319
+ start = alignment['start_frame']
320
+ end = alignment['end_frame']
321
+
322
+ # Check timing consistency
323
+ if start < last_end - 1: # Allow small overlap
324
+ timing_issues += 1
325
+ last_end = end
326
+
327
+ # Check token presence
328
+ if 'tokens' in alignment and alignment['tokens']:
329
+ sample_token_count += len(alignment['tokens'])
330
+ else:
331
+ sample_has_tokens = False
332
+
333
+ if sample_has_tokens and sample_token_count > 0:
334
+ has_tokens += 1
335
+ token_counts.append(sample_token_count)
336
+
337
+ # Calculate frame coverage
338
+ if "snac_tokens" in sample:
339
+ snac_len = len(sample["snac_tokens"])
340
+ total_frames = snac_len // SNAC_LAYERS
341
+ if total_frames > 0 and wa:
342
+ covered_frames = wa[-1]['end_frame'] if wa[-1]['end_frame'] else 0
343
+ frame_coverages.append(min(100, 100 * covered_frames / total_frames))
344
+
345
+ print(f"\n Samples with word_alignments: {has_alignments}/{len(dataset)} ({100*has_alignments/len(dataset):.1f}%)")
346
+
347
+ if not has_alignments:
348
+ print(" No word alignments found!")
349
+ return
350
+
351
+ print(f" Samples with pre-computed tokens: {has_tokens}/{has_alignments} ({100*has_tokens/has_alignments:.1f}%)")
352
+
353
+ word_counts = np.array(word_counts)
354
+ print_subheader("Word Count per Sample")
355
+ print(f" Min: {word_counts.min()}, Max: {word_counts.max()}, Mean: {word_counts.mean():.1f}")
356
+
357
+ if token_counts:
358
+ token_counts = np.array(token_counts)
359
+ print_subheader("Token Count per Sample (from alignments)")
360
+ print(f" Min: {token_counts.min()}, Max: {token_counts.max()}, Mean: {token_counts.mean():.1f}")
361
+
362
+ if frame_coverages:
363
+ fc = np.array(frame_coverages)
364
+ print_subheader("Frame Coverage")
365
+ print(f" Min: {fc.min():.1f}%, Max: {fc.max():.1f}%, Mean: {fc.mean():.1f}%")
366
+
367
+ print_subheader("Quality Checks")
368
+ print(f" Timing issues (overlaps): {timing_issues} {'⚠️' if timing_issues > 0 else '✅'}")
369
+
370
+
371
+ def analyze_cross_field_consistency(dataset):
372
+ """Check consistency between related fields."""
373
+ print_header("CROSS-FIELD CONSISTENCY")
374
+
375
+ issues = defaultdict(int)
376
+
377
+ for i, sample in enumerate(dataset):
378
+ # Check answer vs text_tokens consistency
379
+ if "answer" in sample and "text_tokens" in sample:
380
+ answer = sample["answer"]
381
+ text_tokens = sample["text_tokens"]
382
+ if isinstance(text_tokens, torch.Tensor):
383
+ token_len = len(text_tokens)
384
+ else:
385
+ token_len = len(text_tokens) if text_tokens else 0
386
+
387
+ # Rough check: tokens should be roughly proportional to text length
388
+ if answer and token_len > 0:
389
+ chars_per_token = len(answer) / token_len
390
+ if chars_per_token < 1 or chars_per_token > 10:
391
+ issues["answer_token_mismatch"] += 1
392
+
393
+ # Check word_alignments vs answer consistency
394
+ if "word_alignments" in sample and "answer" in sample:
395
+ wa = sample["word_alignments"]
396
+ answer = sample["answer"]
397
+ if wa and answer:
398
+ wa_words = len(wa)
399
+ answer_words = len(answer.split())
400
+ if abs(wa_words - answer_words) > answer_words * 0.3: # 30% tolerance
401
+ issues["alignment_word_mismatch"] += 1
402
+
403
+ # Check snac_tokens frame count vs alignments
404
+ if "snac_tokens" in sample and "word_alignments" in sample:
405
+ snac = sample["snac_tokens"]
406
+ wa = sample["word_alignments"]
407
+ if isinstance(snac, torch.Tensor) and wa:
408
+ snac_frames = len(snac) // SNAC_LAYERS
409
+ last_frame = max((a.get('end_frame', 0) for a in wa), default=0)
410
+ if last_frame > snac_frames * 1.1: # 10% tolerance
411
+ issues["frame_overflow"] += 1
412
+
413
+ print(f"\n Consistency checks:")
414
+ print(f" Answer/token length mismatch: {issues['answer_token_mismatch']} {'⚠️' if issues['answer_token_mismatch'] > 0 else '✅'}")
415
+ print(f" Alignment/answer word mismatch: {issues['alignment_word_mismatch']} {'⚠️' if issues['alignment_word_mismatch'] > 0 else '✅'}")
416
+ print(f" Frame overflow in alignments: {issues['frame_overflow']} {'⚠️' if issues['frame_overflow'] > 0 else '✅'}")
417
+
418
+
419
+ def analyze_training_readiness(dataset):
420
+ """Check if dataset is ready for training."""
421
+ print_header("TRAINING READINESS CHECK")
422
+
423
+ required_fields = ["whisper_features", "snac_tokens"]
424
+ optional_fields = ["text", "answer", "text_tokens", "word_alignments"]
425
+
426
+ field_presence = defaultdict(int)
427
+ complete_samples = 0
428
+
429
+ issues = []
430
+
431
+ for sample in dataset:
432
+ has_required = all(f in sample for f in required_fields)
433
+ if has_required:
434
+ complete_samples += 1
435
+
436
+ for field in required_fields + optional_fields:
437
+ if field in sample and sample[field] is not None:
438
+ if isinstance(sample[field], (torch.Tensor, list, str)):
439
+ if len(sample[field]) > 0:
440
+ field_presence[field] += 1
441
+ else:
442
+ field_presence[field] += 1
443
+
444
+ print(f"\n Total samples: {len(dataset)}")
445
+ print(f" Complete samples (with required fields): {complete_samples} ({100*complete_samples/len(dataset):.1f}%)")
446
+
447
+ print(f"\n Field presence:")
448
+ for field in required_fields:
449
+ count = field_presence[field]
450
+ status = "✅" if count == len(dataset) else "❌"
451
+ print(f" {field}: {count}/{len(dataset)} {status} (required)")
452
+
453
+ for field in optional_fields:
454
+ count = field_presence[field]
455
+ status = "✅" if count == len(dataset) else "⚠️"
456
+ print(f" {field}: {count}/{len(dataset)} {status} (optional)")
457
+
458
+ # Overall assessment
459
+ print_subheader("Overall Assessment")
460
+
461
+ ready = True
462
+
463
+ if complete_samples < len(dataset):
464
+ print(f" ❌ {len(dataset) - complete_samples} samples missing required fields")
465
+ ready = False
466
+ else:
467
+ print(f" ✅ All samples have required fields")
468
+
469
+ if field_presence.get("text_tokens", 0) == len(dataset):
470
+ print(f" ✅ Pre-tokenized text available (faster training)")
471
+ else:
472
+ print(f" ⚠️ text_tokens missing in some samples (will tokenize on-the-fly)")
473
+
474
+ if field_presence.get("word_alignments", 0) == len(dataset):
475
+ print(f" ✅ Word alignments available (semantic interleaving)")
476
+ else:
477
+ print(f" ⚠️ word_alignments missing (will use positional interleaving)")
478
+
479
+ if ready:
480
+ print(f"\n 🎉 DATASET READY FOR TRAINING!")
481
+ else:
482
+ print(f"\n ⚠️ Dataset has issues that need to be fixed")
483
+
484
+ return ready
485
+
486
+
487
+ def print_sample_inspection(dataset, num_samples=3):
488
+ """Print detailed inspection of sample items."""
489
+ print_header(f"SAMPLE INSPECTION (first {num_samples} samples)")
490
+
491
+ for i in range(min(num_samples, len(dataset))):
492
+ sample = dataset[i]
493
+ print(f"\n Sample {i}:")
494
+
495
+ for key, value in sample.items():
496
+ if isinstance(value, torch.Tensor):
497
+ print(f" {key}: Tensor {list(value.shape)} {value.dtype}")
498
+ if key == "snac_tokens" and len(value) > 0:
499
+ print(f" First 7 tokens: {value[:7].tolist()}")
500
+ print(f" Last 7 tokens: {value[-7:].tolist()}")
501
+ elif isinstance(value, str):
502
+ preview = value[:60] + "..." if len(value) > 60 else value
503
+ print(f" {key}: '{preview}'")
504
+ elif isinstance(value, list):
505
+ print(f" {key}: list[{len(value)}]")
506
+ if key == "word_alignments" and len(value) > 0:
507
+ print(f" First: {value[0]}")
508
+ if len(value) > 1:
509
+ print(f" Last: {value[-1]}")
510
+ else:
511
+ print(f" {key}: {type(value).__name__}")
512
+
513
+
514
+ def main():
515
+ parser = argparse.ArgumentParser(description="Deep dataset analysis")
516
+ parser.add_argument("--path", type=str, required=True, help="Path to dataset .pt file")
517
+ parser.add_argument("--max-samples", type=int, default=None, help="Max samples to analyze")
518
+ parser.add_argument("--quick", action="store_true", help="Quick analysis (skip detailed checks)")
519
+ args = parser.parse_args()
520
+
521
+ print("\n" + "="*70)
522
+ print(" DEEP DATASET ANALYZER")
523
+ print("="*70)
524
+ print(f"\n File: {args.path}")
525
+
526
+ # Check file exists
527
+ if not Path(args.path).exists():
528
+ print(f"\n ❌ File not found: {args.path}")
529
+ sys.exit(1)
530
+
531
+ # Load dataset
532
+ print(f" Loading dataset...")
533
+ try:
534
+ dataset = torch.load(args.path, map_location="cpu", weights_only=False)
535
+ except Exception as e:
536
+ print(f"\n ❌ Failed to load: {e}")
537
+ sys.exit(1)
538
+
539
+ if not isinstance(dataset, list):
540
+ print(f"\n ❌ Dataset should be list, got {type(dataset).__name__}")
541
+ sys.exit(1)
542
+
543
+ print(f" Loaded {len(dataset):,} samples")
544
+
545
+ # Limit samples if requested
546
+ if args.max_samples and args.max_samples < len(dataset):
547
+ print(f" Analyzing first {args.max_samples} samples")
548
+ dataset = dataset[:args.max_samples]
549
+
550
+ # File size
551
+ file_size = Path(args.path).stat().st_size / (1024**3)
552
+ print(f" File size: {file_size:.2f} GB")
553
+ print(f" Avg per sample: {file_size*1024/len(dataset):.2f} MB")
554
+
555
+ # Run analyses
556
+ analyze_whisper_features(dataset)
557
+ analyze_snac_tokens(dataset)
558
+ analyze_text_content(dataset)
559
+
560
+ if not args.quick:
561
+ analyze_word_alignments(dataset)
562
+ analyze_cross_field_consistency(dataset)
563
+
564
+ ready = analyze_training_readiness(dataset)
565
+ print_sample_inspection(dataset)
566
+
567
+ print("\n" + "="*70)
568
+ print(" ANALYSIS COMPLETE")
569
+ print("="*70 + "\n")
570
+
571
+ sys.exit(0 if ready else 1)
572
+
573
+
574
+ if __name__ == "__main__":
575
+ main()
datasets/create_dataset.py CHANGED
@@ -8,6 +8,7 @@ import sys
8
  import re
9
  import time
10
  import gc
 
11
  import multiprocessing as mp
12
  from pathlib import Path
13
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -15,6 +16,43 @@ import numpy as np
15
  import requests
16
  import torch
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  def load_dotenv(env_path=None):
20
  """Load environment variables from .env file."""
@@ -31,7 +69,7 @@ def load_dotenv(env_path=None):
31
  if line and not line.startswith("#") and "=" in line:
32
  key, value = line.split("=", 1)
33
  os.environ.setdefault(key.strip(), value.strip())
34
- print(f"[ENV] Loaded from {env_path}")
35
 
36
 
37
  # Load .env file
@@ -40,7 +78,7 @@ load_dotenv()
40
  # Configuration
41
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
42
  GROQ_MODEL = "openai/gpt-oss-20b"
43
- GROQ_PARALLEL_REQUESTS = 3
44
  QA_PER_REQUEST = 100
45
 
46
 
@@ -64,7 +102,7 @@ MEMORY_PER_ITEM = {
64
  # Default batch sizes (will be auto-adjusted based on VRAM)
65
  DEFAULT_BATCH_SIZES = {
66
  "tts": 200, # TTS batch (processes questions + answers)
67
- "whisper": 8, # Whisper parallel threads (CPU-bound, low GPU mem)
68
  "snac": 50, # SNAC encoding batch
69
  }
70
 
@@ -84,13 +122,13 @@ def calculate_batch_sizes(vram_gb: float = None, shared_gpu: bool = True) -> dic
84
  so batch sizes must be more conservative to avoid OOM.
85
 
86
  VRAM tiers (with shared GPU adjustment):
87
- - 80GB+ (H100/H200): Full TTS, conservative SNAC
88
- - 40-80GB (A100/H100-64GB): 75% TTS, conservative SNAC
89
- - 24-40GB (RTX 4090): 50% TTS, minimal SNAC
90
- - 16-24GB (RTX 3090/4080): 33% TTS, minimal SNAC
91
  - <16GB: Minimum safe values
92
 
93
- Returns dict with: tts, whisper, snac batch sizes
94
  """
95
  if vram_gb is None:
96
  vram_gb = get_gpu_vram_gb()
@@ -98,29 +136,35 @@ def calculate_batch_sizes(vram_gb: float = None, shared_gpu: bool = True) -> dic
98
  # Determine scale factor based on VRAM
99
  if vram_gb >= 80:
100
  tts_scale = 1.0
101
- snac_scale = 0.5 # SNAC needs to be conservative when sharing GPU
 
102
  elif vram_gb >= 40:
103
  tts_scale = 0.75
104
- snac_scale = 0.4 # More conservative for 64GB GPUs
 
105
  elif vram_gb >= 24:
106
  tts_scale = 0.5
107
- snac_scale = 0.3
 
108
  elif vram_gb >= 16:
109
  tts_scale = 0.33
110
- snac_scale = 0.2
 
111
  else:
112
  tts_scale = 0.2
 
113
  snac_scale = 0.15
114
 
115
- # If not sharing GPU, SNAC can use more memory
116
  if not shared_gpu:
117
- snac_scale = min(1.0, snac_scale * 2)
 
118
 
119
  # Calculate batch sizes
120
  batch_sizes = {
121
  "tts": max(10, int(DEFAULT_BATCH_SIZES["tts"] * tts_scale)),
122
- "whisper": max(2, int(DEFAULT_BATCH_SIZES["whisper"] * tts_scale)),
123
- "snac": max(4, int(DEFAULT_BATCH_SIZES["snac"] * snac_scale)),
124
  }
125
 
126
  return batch_sizes
@@ -128,8 +172,8 @@ def calculate_batch_sizes(vram_gb: float = None, shared_gpu: bool = True) -> dic
128
 
129
  def print_batch_config(batch_sizes: dict, vram_gb: float):
130
  """Print batch configuration for transparency."""
131
- print(f"[Config] GPU VRAM: {vram_gb:.1f}GB")
132
- print(f"[Config] Batch sizes: TTS={batch_sizes['tts']}, Whisper={batch_sizes['whisper']}, SNAC={batch_sizes['snac']}")
133
 
134
 
135
  def parse_qa(content):
@@ -217,14 +261,14 @@ def qa_producer(target_count: int, tts_queue: mp.Queue, batch_size: int, status_
217
  status_queue.put(("qa_done", len(pairs), batch_idx))
218
 
219
 
220
- def tts_worker(gpu_id: int, tts_queue: mp.Queue, feat_queue: mp.Queue, status_queue: mp.Queue, batch_sizes: dict, num_gpus: int = 1):
221
  """TTS worker - processes batches with VRAM-adjusted batch sizes on specific GPU."""
222
  import torch
223
  _orig_load = torch.load
224
  torch.load = lambda *a, **kw: _orig_load(*a, **{**kw, 'weights_only': False})
225
 
226
- # Set specific GPU for this worker (distribute across available GPUs)
227
- actual_gpu = gpu_id % num_gpus
228
  torch.cuda.set_device(actual_gpu)
229
  print(f"[TTS-GPU{gpu_id}] Assigned to CUDA device {actual_gpu}")
230
 
@@ -240,19 +284,30 @@ def tts_worker(gpu_id: int, tts_queue: mp.Queue, feat_queue: mp.Queue, status_qu
240
  from soprano import SopranoTTS
241
  # Use lmdeploy backend for 2000x real-time speed (much faster than transformers)
242
  # Scale decoder_batch_size based on VRAM
243
- try:
244
- vram_gb = torch.cuda.get_device_properties(actual_gpu).total_memory / 1024**3
245
- dec_batch = 32 if vram_gb >= 80 else (16 if vram_gb >= 40 else 8)
246
- tts = SopranoTTS(
247
- backend="lmdeploy", # Fastest backend
248
- device=f"cuda:{actual_gpu}", # Specific GPU
249
- cache_size_mb=4000 if vram_gb >= 24 else 2000, # More cache = faster
250
- decoder_batch_size=dec_batch, # Parallel decoding based on VRAM
251
- )
252
- print(f"[TTS-GPU{gpu_id}] Using lmdeploy backend (decoder_batch={dec_batch}, cache={4000 if vram_gb >= 24 else 2000}MB)")
253
- except Exception as e:
254
- print(f"[TTS-GPU{gpu_id}] lmdeploy failed ({e}), falling back to transformers")
255
- tts = SopranoTTS(backend="transformers", device=f"cuda:{actual_gpu}")
 
 
 
 
 
 
 
 
 
 
 
256
 
257
  # Use centralized batch size
258
  tts_batch = batch_sizes.get("tts", 200)
@@ -262,8 +317,20 @@ def tts_worker(gpu_id: int, tts_queue: mp.Queue, feat_queue: mp.Queue, status_qu
262
 
263
  processed = 0
264
  t_start = time.time()
 
 
265
  while True:
266
- item = tts_queue.get()
 
 
 
 
 
 
 
 
 
 
267
  if item is None:
268
  break
269
 
@@ -291,7 +358,10 @@ def tts_worker(gpu_id: int, tts_queue: mp.Queue, feat_queue: mp.Queue, status_qu
291
  })
292
 
293
  except Exception as e:
294
- status_queue.put(("tts_error", gpu_id, str(e)))
 
 
 
295
 
296
  if all_results:
297
  feat_queue.put((batch_idx, all_results))
@@ -304,7 +374,7 @@ def tts_worker(gpu_id: int, tts_queue: mp.Queue, feat_queue: mp.Queue, status_qu
304
  status_queue.put(("tts_done", gpu_id, processed))
305
 
306
 
307
- def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, status_queue: mp.Queue, batch_sizes: dict, num_gpus: int = 1):
308
  """Features worker - VRAM-adjusted batch sizes for Whisper + SNAC + NeMo NFA on specific GPU."""
309
  import torch
310
  _orig = torch.load
@@ -317,8 +387,8 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
317
  from transformers import AutoTokenizer, WhisperModel, WhisperFeatureExtractor
318
  from huggingface_hub import login
319
 
320
- # Set specific GPU for this worker (distribute across available GPUs)
321
- actual_gpu = gpu_id % num_gpus
322
  torch.cuda.set_device(actual_gpu)
323
  device = f"cuda:{actual_gpu}"
324
  print(f"[Features-GPU{gpu_id}] Assigned to CUDA device {actual_gpu}")
@@ -335,6 +405,9 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
335
  print("[Features] Loading Whisper large-v3-turbo (transformers)...")
336
  whisper_model = WhisperModel.from_pretrained("openai/whisper-large-v3-turbo", torch_dtype=torch.float16).to(device).eval()
337
  whisper_feature_extractor = WhisperFeatureExtractor.from_pretrained("openai/whisper-large-v3-turbo")
 
 
 
338
  print("[Features] Whisper Turbo loaded successfully")
339
 
340
  snac_model = snac.SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device).eval()
@@ -371,20 +444,26 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
371
  status_queue.put(("feat_ready", gpu_id))
372
 
373
  def process_whisper(audio_data):
374
- # Resample to 16kHz for Whisper
375
- audio_16k = torchaudio.functional.resample(
376
- torch.from_numpy(audio_data), 32000, 16000
377
- ).numpy().astype(np.float32)
 
378
  # Truncate to max 30 seconds (480000 samples at 16kHz) - Whisper limit
379
  max_samples = 480000
380
- if len(audio_16k) > max_samples:
381
  audio_16k = audio_16k[:max_samples]
 
 
 
382
  # Extract features using Whisper feature extractor
383
- inputs = whisper_feature_extractor(audio_16k, sampling_rate=16000, return_tensors="pt")
384
  input_features = inputs.input_features.to(device, dtype=torch.float16)
 
385
  # Encode with Whisper
386
  with torch.no_grad():
387
  encoder_outputs = whisper_model.encoder(input_features)
 
388
  # Return encoder hidden states [seq_len, 1280]
389
  return encoder_outputs.last_hidden_state.squeeze(0).cpu().float()
390
 
@@ -493,28 +572,55 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
493
  return None
494
 
495
  def get_word_alignments_proportional(audio_data, text, sample_rate=32000):
496
- """Fallback: proportional word alignment based on character count."""
 
 
 
 
 
497
  words = text.split()
498
  if not words:
499
  return []
500
 
501
- audio_duration = len(audio_data) / sample_rate
 
 
 
 
 
 
 
502
  total_chars = sum(len(w) for w in words)
503
  if total_chars == 0:
504
  return []
505
 
 
506
  word_alignments = []
507
- current_time = 0.0
508
- for word in words:
509
- word_duration = (len(word) / total_chars) * audio_duration
510
- start_time = current_time
511
- end_time = current_time + word_duration
512
- start_frame = int(start_time * 75)
513
- end_frame = int(end_time * 75)
514
-
515
- word_tokens = []
516
- if tokenizer is not None:
517
- word_tokens = tokenizer.encode(word, add_special_tokens=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
 
519
  word_alignments.append({
520
  'word': word,
@@ -522,17 +628,28 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
522
  'end': end_time,
523
  'start_frame': start_frame,
524
  'end_frame': end_frame,
525
- 'tokens': word_tokens
526
  })
527
- current_time = end_time
528
 
529
  return word_alignments
530
 
531
  processed = 0
532
  t_start = time.time()
 
533
 
534
  while True:
535
- item = feat_queue.get()
 
 
 
 
 
 
 
 
 
 
536
  if item is None:
537
  break
538
 
@@ -540,12 +657,12 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
540
  t0 = time.time()
541
 
542
  try:
543
- # 1. Parallel Whisper encoding - CPU-bound, low GPU memory
544
  q_audios = [ad["q_audio"] for ad in audio_batch]
545
  with ThreadPoolExecutor(max_workers=whisper_workers) as ex:
546
  whisper_features = list(ex.map(process_whisper, q_audios))
547
 
548
- # 2. SNAC encoding - batch size from centralized config
549
  a_audios = [ad["a_audio"] for ad in audio_batch]
550
  all_tokens = []
551
 
@@ -555,8 +672,12 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
555
 
556
  max_len = max(a.shape[0] for a in mini_audios)
557
  padded = [np.pad(a, (0, max_len - len(a))) for a in mini_audios]
 
 
558
  audios_24k = torch.stack([
559
- torchaudio.functional.resample(torch.from_numpy(a), 32000, 24000)
 
 
560
  for a in padded
561
  ])
562
 
@@ -568,6 +689,11 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
568
 
569
  torch.cuda.synchronize()
570
 
 
 
 
 
 
571
  # 3. Build results with pre-computed text tokens and word alignments
572
  results = []
573
  for i, ad in enumerate(audio_batch):
@@ -596,15 +722,48 @@ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, s
596
  batch_time = time.time() - t0
597
  batch_rate = len(results) / batch_time if batch_time > 0 else 0
598
 
599
- print(f"[DEBUG] Putting {len(results)} results to queue (batch {batch_idx})...")
600
- result_queue.put((batch_idx, results))
601
- print(f"[DEBUG] Put complete for batch {batch_idx}")
 
 
 
 
 
 
 
 
 
 
 
 
602
  processed += len(results)
603
  elapsed = time.time() - t_start
604
  status_queue.put(("feat", gpu_id, processed, processed/elapsed, batch_rate))
605
 
 
 
 
 
 
 
606
  except Exception as e:
607
- status_queue.put(("feat_error", gpu_id, str(e)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
 
609
  status_queue.put(("feat_done", gpu_id, processed))
610
 
@@ -619,9 +778,12 @@ def main():
619
 
620
  import argparse
621
  parser = argparse.ArgumentParser()
622
- parser.add_argument("--count", type=int, default=100)
623
  parser.add_argument("--output", type=str, default="./data/dataset.pt")
624
  parser.add_argument("--gpus", type=int, default=NUM_GPUS)
 
 
 
625
  # Optional overrides for batch sizes (if not set, auto-calculated from VRAM)
626
  parser.add_argument("--tts-batch", type=int, default=None)
627
  parser.add_argument("--snac-batch", type=int, default=None)
@@ -630,6 +792,11 @@ def main():
630
 
631
  Path(args.output).parent.mkdir(parents=True, exist_ok=True)
632
 
 
 
 
 
 
633
  # Calculate batch sizes based on GPU VRAM
634
  vram_gb = get_gpu_vram_gb()
635
  batch_sizes = calculate_batch_sizes(vram_gb)
@@ -642,42 +809,115 @@ def main():
642
  if args.whisper_workers is not None:
643
  batch_sizes["whisper"] = args.whisper_workers
644
 
645
- print("=" * 60)
646
- print("Dataset Generator - Fully Async Pipeline")
647
- print(f"Target: {args.count} items, GPUs: {args.gpus}")
648
  print_batch_config(batch_sizes, vram_gb)
649
- print("=" * 60)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
 
651
  total_start = time.time()
652
 
653
- # Use Manager for more reliable cross-process queue communication
654
- manager = mp.Manager()
655
- tts_queue = mp.Queue(maxsize=100)
656
- feat_queue = mp.Queue(maxsize=100)
657
- result_queue = manager.Queue() # Manager queue is more reliable with spawn
 
658
  status_queue = mp.Queue()
659
 
660
  workers = []
661
 
662
  # Get actual GPU count for worker assignment
663
  actual_num_gpus = get_num_gpus() if torch.cuda.is_available() else 1
664
- print(f"\n[Main] Detected {actual_num_gpus} GPUs, spawning {args.gpus} workers per stage")
665
 
666
- qa_proc = mp.Process(target=qa_producer, args=(args.count, tts_queue, batch_sizes["tts"], status_queue, args.gpus))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  qa_proc.start()
668
  workers.append(qa_proc)
669
 
670
- for gpu_id in range(args.gpus):
671
- p = mp.Process(target=tts_worker, args=(gpu_id, tts_queue, feat_queue, status_queue, batch_sizes, actual_num_gpus))
672
  p.start()
673
  workers.append(p)
674
 
675
- for gpu_id in range(args.gpus):
676
- p = mp.Process(target=features_worker, args=(gpu_id, feat_queue, result_queue, status_queue, batch_sizes, actual_num_gpus))
677
  p.start()
678
  workers.append(p)
679
 
680
- print("[Pipeline] All workers started, monitoring...")
681
 
682
  results = {}
683
  tts_done_count = 0
@@ -686,124 +926,311 @@ def main():
686
  t0 = time.time()
687
  tts_ready = 0
688
  feat_ready = 0
689
- expected_from_feat = {} # Track expected items per GPU
690
- feat_queue_closed = False # Track if we've sent None to feat workers
 
691
 
692
- # Main loop: alternate between checking status and collecting results
693
  last_result_time = time.time()
 
 
 
 
 
 
694
  while True:
695
- # Process status messages (non-blocking)
696
- status_processed = False
697
- while True:
698
  try:
699
  msg = status_queue.get_nowait()
700
- status_processed = True
701
  msg_type = msg[0]
702
 
703
  if msg_type == "tts_ready":
704
  tts_ready += 1
705
- print(f"[TTS-GPU{msg[1]}] Ready ({tts_ready}/{args.gpus})")
706
  elif msg_type == "feat_ready":
707
  feat_ready += 1
708
- print(f"[Features-GPU{msg[1]}] Ready ({feat_ready}/{args.gpus})")
709
  elif msg_type == "qa":
710
- print(f"[Q&A] {msg[1]}/{msg[2]} | {msg[3]:.1f}/s")
711
  elif msg_type == "qa_done":
712
- print(f"[Q&A] Done: {msg[1]} pairs, {msg[2]} batches")
713
  elif msg_type == "tts":
714
- print(f"[TTS-GPU{msg[1]}] {msg[2]} items | avg {msg[3]:.1f}/s | batch {msg[4]:.1f}/s")
 
715
  elif msg_type == "tts_done":
716
  tts_done_count += 1
717
- print(f"[TTS-GPU{msg[1]}] Done: {msg[2]} items")
718
  elif msg_type == "feat":
719
- print(f"[Feat-GPU{msg[1]}] {msg[2]} items | avg {msg[3]:.1f}/s | batch {msg[4]:.1f}/s")
 
720
  elif msg_type == "feat_done":
721
  feat_done_count += 1
722
  expected_from_feat[msg[1]] = msg[2]
723
- print(f"[Features-GPU{msg[1]}] Done: {msg[2]} items")
 
 
 
 
 
 
724
  elif "error" in msg_type:
725
- print(f"[Error] {msg}")
 
726
  except:
727
  break
728
 
729
- # TTS workers now send None to feat_queue directly when done
730
- # This ensures all batches are sent before close signal
731
- if tts_done_count >= args.gpus and not feat_queue_closed:
732
- print(f"[Main] TTS done, waiting for features to finish...")
733
  feat_queue_closed = True
734
 
735
- # Try to collect results (blocking with short timeout)
736
- try:
737
- batch_idx, items = result_queue.get(timeout=0.1)
738
- results[batch_idx] = items
739
- total_items += len(items)
740
- last_result_time = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  elapsed = time.time() - t0
742
- print(f"[Results] {total_items}/{args.count} | {total_items/elapsed:.1f}/s")
743
- except:
744
- pass # No results available right now
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
 
746
  # Check exit conditions
747
- if total_items >= args.count:
748
- print(f"[Main] Target reached: {total_items}/{args.count}")
749
  break
750
 
751
- # If all workers done, keep draining with longer timeout
752
- if feat_done_count >= args.gpus:
753
- drain_timeout = 5.0
754
- print(f"[Main] All workers done, draining queue (timeout={drain_timeout}s)...")
755
  drain_start = time.time()
756
- while time.time() - drain_start < drain_timeout:
757
  try:
758
  batch_idx, items = result_queue.get(timeout=0.5)
759
  results[batch_idx] = items
760
  total_items += len(items)
761
- print(f"[Results] {total_items}/{args.count} | (drained)")
762
  except:
763
- # No more items, check if we should keep waiting
764
- if time.time() - drain_start > 1.0:
 
 
 
 
 
765
  break
766
  break
767
 
768
- # Safety timeout: if no progress for 60 seconds, exit
769
- if time.time() - last_result_time > 60 and total_items > 0:
770
- print(f"[WARN] No progress for 60s, stopping with {total_items} items")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  break
772
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773
  # Wait for workers to finish
774
- print("[Main] Waiting for workers to finish...")
775
  for p in workers:
776
  p.join(timeout=5)
777
  if p.is_alive():
778
  p.terminate()
779
 
780
- # Final drain (quick)
781
- while True:
782
  try:
783
  batch_idx, items = result_queue.get_nowait()
784
  results[batch_idx] = items
785
  total_items += len(items)
786
- print(f"[Results] {total_items}/{args.count} | (final)")
787
  except:
788
  break
789
 
790
- items = []
 
791
  for i in sorted(results.keys()):
792
- items.extend(results[i])
793
- items = items[:args.count]
 
794
 
795
- if len(items) == 0:
796
- print("[ERROR] No items collected! Check worker logs.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
797
  sys.exit(1)
798
 
799
- torch.save(items, args.output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
 
801
  total_time = time.time() - total_start
802
- print("\n" + "=" * 60)
803
- print(f"COMPLETE: {len(items)} items saved to {args.output}")
804
- print(f"Total time: {total_time:.1f}s ({total_time/60:.1f}m)")
805
- print(f"Throughput: {len(items)/total_time:.2f} items/s")
806
- print("=" * 60)
 
 
807
 
808
 
809
  if __name__ == "__main__":
 
8
  import re
9
  import time
10
  import gc
11
+ import logging
12
  import multiprocessing as mp
13
  from pathlib import Path
14
  from concurrent.futures import ThreadPoolExecutor, as_completed
 
16
  import requests
17
  import torch
18
 
19
+ # Global logger
20
+ logger = None
21
+
22
+ def setup_logging(log_file=None):
23
+ """Setup logging to both console and file."""
24
+ global logger
25
+ logger = logging.getLogger("dataset_generator")
26
+ logger.setLevel(logging.INFO)
27
+ logger.handlers.clear()
28
+
29
+ # Console handler
30
+ console = logging.StreamHandler(sys.stdout)
31
+ console.setLevel(logging.INFO)
32
+ console.setFormatter(logging.Formatter('%(message)s'))
33
+ logger.addHandler(console)
34
+
35
+ # File handler (if specified)
36
+ if log_file:
37
+ file_handler = logging.FileHandler(log_file, mode='a')
38
+ file_handler.setLevel(logging.INFO)
39
+ file_handler.setFormatter(logging.Formatter('%(asctime)s | %(message)s', datefmt='%H:%M:%S'))
40
+ logger.addHandler(file_handler)
41
+
42
+ return logger
43
+
44
+ def log(msg):
45
+ """Log message to both console and file."""
46
+ global logger
47
+ if logger:
48
+ logger.info(msg)
49
+ # Force flush all handlers
50
+ for handler in logger.handlers:
51
+ handler.flush()
52
+ else:
53
+ print(msg)
54
+ sys.stdout.flush()
55
+
56
 
57
  def load_dotenv(env_path=None):
58
  """Load environment variables from .env file."""
 
69
  if line and not line.startswith("#") and "=" in line:
70
  key, value = line.split("=", 1)
71
  os.environ.setdefault(key.strip(), value.strip())
72
+ log(f"[ENV] Loaded from {env_path}")
73
 
74
 
75
  # Load .env file
 
78
  # Configuration
79
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
80
  GROQ_MODEL = "openai/gpt-oss-20b"
81
+ GROQ_PARALLEL_REQUESTS = 10
82
  QA_PER_REQUEST = 100
83
 
84
 
 
102
  # Default batch sizes (will be auto-adjusted based on VRAM)
103
  DEFAULT_BATCH_SIZES = {
104
  "tts": 200, # TTS batch (processes questions + answers)
105
+ "whisper": 8, # Whisper parallel workers (each uses GPU)
106
  "snac": 50, # SNAC encoding batch
107
  }
108
 
 
122
  so batch sizes must be more conservative to avoid OOM.
123
 
124
  VRAM tiers (with shared GPU adjustment):
125
+ - 80GB+ (H100/H200): Large batches
126
+ - 40-80GB (A100/H100-64GB): Medium-large batches
127
+ - 24-40GB (RTX 4090): Medium batches
128
+ - 16-24GB (RTX 3090/4080): Smaller batches
129
  - <16GB: Minimum safe values
130
 
131
+ Returns dict with: tts, whisper (workers), snac batch sizes
132
  """
133
  if vram_gb is None:
134
  vram_gb = get_gpu_vram_gb()
 
136
  # Determine scale factor based on VRAM
137
  if vram_gb >= 80:
138
  tts_scale = 1.0
139
+ whisper_workers = 8 # Parallel Whisper threads
140
+ snac_scale = 0.6
141
  elif vram_gb >= 40:
142
  tts_scale = 0.75
143
+ whisper_workers = 6
144
+ snac_scale = 0.5
145
  elif vram_gb >= 24:
146
  tts_scale = 0.5
147
+ whisper_workers = 4 # RTX 4090 - moderate parallelism
148
+ snac_scale = 0.4
149
  elif vram_gb >= 16:
150
  tts_scale = 0.33
151
+ whisper_workers = 2
152
+ snac_scale = 0.25
153
  else:
154
  tts_scale = 0.2
155
+ whisper_workers = 1
156
  snac_scale = 0.15
157
 
158
+ # If not sharing GPU, can use more memory
159
  if not shared_gpu:
160
+ snac_scale = min(1.0, snac_scale * 1.5)
161
+ whisper_workers = min(8, whisper_workers + 2)
162
 
163
  # Calculate batch sizes
164
  batch_sizes = {
165
  "tts": max(10, int(DEFAULT_BATCH_SIZES["tts"] * tts_scale)),
166
+ "whisper": whisper_workers, # Thread count for parallel Whisper
167
+ "snac": max(8, int(DEFAULT_BATCH_SIZES["snac"] * snac_scale)),
168
  }
169
 
170
  return batch_sizes
 
172
 
173
  def print_batch_config(batch_sizes: dict, vram_gb: float):
174
  """Print batch configuration for transparency."""
175
+ log(f"[Config] GPU VRAM: {vram_gb:.1f}GB")
176
+ log(f"[Config] Batch sizes: TTS={batch_sizes['tts']}, Whisper={batch_sizes['whisper']}, SNAC={batch_sizes['snac']}")
177
 
178
 
179
  def parse_qa(content):
 
261
  status_queue.put(("qa_done", len(pairs), batch_idx))
262
 
263
 
264
+ def tts_worker(gpu_id: int, tts_queue: mp.Queue, feat_queue: mp.Queue, status_queue: mp.Queue, batch_sizes: dict, num_gpus: int = 1, gpu_offset: int = 0):
265
  """TTS worker - processes batches with VRAM-adjusted batch sizes on specific GPU."""
266
  import torch
267
  _orig_load = torch.load
268
  torch.load = lambda *a, **kw: _orig_load(*a, **{**kw, 'weights_only': False})
269
 
270
+ # Set specific GPU for this worker (distribute across available GPUs with offset)
271
+ actual_gpu = gpu_offset + (gpu_id % num_gpus)
272
  torch.cuda.set_device(actual_gpu)
273
  print(f"[TTS-GPU{gpu_id}] Assigned to CUDA device {actual_gpu}")
274
 
 
284
  from soprano import SopranoTTS
285
  # Use lmdeploy backend for 2000x real-time speed (much faster than transformers)
286
  # Scale decoder_batch_size based on VRAM
287
+ # Note: Soprano TTS only accepts "cuda", not "cuda:N"
288
+ # torch.cuda.set_device() already selected the correct GPU above
289
+ vram_gb = torch.cuda.get_device_properties(actual_gpu).total_memory / 1024**3
290
+ gpu_name = torch.cuda.get_device_properties(actual_gpu).name
291
+
292
+ # Check if GPU supports lmdeploy (Blackwell/RTX 50xx not supported yet)
293
+ use_lmdeploy = "5090" not in gpu_name and "5080" not in gpu_name and "B100" not in gpu_name
294
+
295
+ if use_lmdeploy:
296
+ try:
297
+ dec_batch = 32 if vram_gb >= 80 else (16 if vram_gb >= 40 else 8)
298
+ tts = SopranoTTS(
299
+ backend="lmdeploy", # Fastest backend
300
+ device="cuda", # Uses current device set by torch.cuda.set_device()
301
+ cache_size_mb=4000 if vram_gb >= 24 else 2000, # More cache = faster
302
+ decoder_batch_size=dec_batch, # Parallel decoding based on VRAM
303
+ )
304
+ print(f"[TTS-GPU{gpu_id}] Using lmdeploy backend (decoder_batch={dec_batch})")
305
+ except Exception as e:
306
+ print(f"[TTS-GPU{gpu_id}] lmdeploy failed ({e}), falling back to transformers")
307
+ tts = SopranoTTS(backend="transformers", device="cuda")
308
+ else:
309
+ print(f"[TTS-GPU{gpu_id}] Blackwell GPU detected ({gpu_name}), using transformers backend")
310
+ tts = SopranoTTS(backend="transformers", device="cuda")
311
 
312
  # Use centralized batch size
313
  tts_batch = batch_sizes.get("tts", 200)
 
317
 
318
  processed = 0
319
  t_start = time.time()
320
+ last_heartbeat = time.time()
321
+
322
  while True:
323
+ # Send heartbeat every 30 seconds to show worker is alive
324
+ if time.time() - last_heartbeat > 30:
325
+ status_queue.put(("tts_heartbeat", gpu_id, processed))
326
+ last_heartbeat = time.time()
327
+
328
+ # Use timeout to allow heartbeat even when queue is empty
329
+ try:
330
+ item = tts_queue.get(timeout=5)
331
+ except:
332
+ continue # Timeout, send heartbeat and retry
333
+
334
  if item is None:
335
  break
336
 
 
358
  })
359
 
360
  except Exception as e:
361
+ import traceback
362
+ status_queue.put(("tts_error", gpu_id, f"{str(e)}\n{traceback.format_exc()}"))
363
+ # Continue processing other batches instead of crashing
364
+ continue
365
 
366
  if all_results:
367
  feat_queue.put((batch_idx, all_results))
 
374
  status_queue.put(("tts_done", gpu_id, processed))
375
 
376
 
377
+ def features_worker(gpu_id: int, feat_queue: mp.Queue, result_queue: mp.Queue, status_queue: mp.Queue, batch_sizes: dict, num_gpus: int = 1, gpu_offset: int = 0):
378
  """Features worker - VRAM-adjusted batch sizes for Whisper + SNAC + NeMo NFA on specific GPU."""
379
  import torch
380
  _orig = torch.load
 
387
  from transformers import AutoTokenizer, WhisperModel, WhisperFeatureExtractor
388
  from huggingface_hub import login
389
 
390
+ # Set specific GPU for this worker (distribute across available GPUs with offset)
391
+ actual_gpu = gpu_offset + (gpu_id % num_gpus)
392
  torch.cuda.set_device(actual_gpu)
393
  device = f"cuda:{actual_gpu}"
394
  print(f"[Features-GPU{gpu_id}] Assigned to CUDA device {actual_gpu}")
 
405
  print("[Features] Loading Whisper large-v3-turbo (transformers)...")
406
  whisper_model = WhisperModel.from_pretrained("openai/whisper-large-v3-turbo", torch_dtype=torch.float16).to(device).eval()
407
  whisper_feature_extractor = WhisperFeatureExtractor.from_pretrained("openai/whisper-large-v3-turbo")
408
+
409
+ # Note: torch.compile is skipped for Whisper as it causes issues with conv1d
410
+ # The batched processing already provides significant speedup
411
  print("[Features] Whisper Turbo loaded successfully")
412
 
413
  snac_model = snac.SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device).eval()
 
444
  status_queue.put(("feat_ready", gpu_id))
445
 
446
  def process_whisper(audio_data):
447
+ """Process single audio with Whisper. GPU-accelerated resampling."""
448
+ # GPU-accelerated resampling
449
+ audio_tensor = torch.from_numpy(audio_data).to(device)
450
+ audio_16k = torchaudio.functional.resample(audio_tensor, 32000, 16000)
451
+
452
  # Truncate to max 30 seconds (480000 samples at 16kHz) - Whisper limit
453
  max_samples = 480000
454
+ if audio_16k.shape[0] > max_samples:
455
  audio_16k = audio_16k[:max_samples]
456
+
457
+ audio_16k_np = audio_16k.cpu().numpy().astype(np.float32)
458
+
459
  # Extract features using Whisper feature extractor
460
+ inputs = whisper_feature_extractor(audio_16k_np, sampling_rate=16000, return_tensors="pt")
461
  input_features = inputs.input_features.to(device, dtype=torch.float16)
462
+
463
  # Encode with Whisper
464
  with torch.no_grad():
465
  encoder_outputs = whisper_model.encoder(input_features)
466
+
467
  # Return encoder hidden states [seq_len, 1280]
468
  return encoder_outputs.last_hidden_state.squeeze(0).cpu().float()
469
 
 
572
  return None
573
 
574
  def get_word_alignments_proportional(audio_data, text, sample_rate=32000):
575
+ """Fallback: proportional word alignment based on character count.
576
+
577
+ Calculates frame indices that match actual SNAC output:
578
+ - SNAC operates at 24kHz with ~320 samples per frame (75 fps)
579
+ - Audio is resampled from sample_rate to 24kHz before SNAC
580
+ """
581
  words = text.split()
582
  if not words:
583
  return []
584
 
585
+ # Calculate actual SNAC frame count after resampling to 24kHz
586
+ # SNAC uses ~320 samples per frame at 24kHz
587
+ audio_24k_samples = len(audio_data) * 24000 / sample_rate
588
+ total_snac_frames = int(audio_24k_samples / 320)
589
+
590
+ if total_snac_frames == 0:
591
+ return []
592
+
593
  total_chars = sum(len(w) for w in words)
594
  if total_chars == 0:
595
  return []
596
 
597
+ audio_duration = len(audio_data) / sample_rate
598
  word_alignments = []
599
+ current_frame = 0
600
+
601
+ # Pre-tokenize all words in batch for efficiency
602
+ all_word_tokens = []
603
+ if tokenizer is not None:
604
+ for word in words:
605
+ all_word_tokens.append(tokenizer.encode(word, add_special_tokens=False))
606
+ else:
607
+ all_word_tokens = [[] for _ in words]
608
+
609
+ for i, word in enumerate(words):
610
+ # Distribute frames proportionally based on character count
611
+ word_frames = int((len(word) / total_chars) * total_snac_frames)
612
+
613
+ # Ensure last word gets remaining frames
614
+ if i == len(words) - 1:
615
+ end_frame = total_snac_frames
616
+ else:
617
+ end_frame = min(current_frame + max(1, word_frames), total_snac_frames)
618
+
619
+ start_frame = current_frame
620
+
621
+ # Calculate time from frames (for compatibility)
622
+ start_time = start_frame / 75.0
623
+ end_time = end_frame / 75.0
624
 
625
  word_alignments.append({
626
  'word': word,
 
628
  'end': end_time,
629
  'start_frame': start_frame,
630
  'end_frame': end_frame,
631
+ 'tokens': all_word_tokens[i]
632
  })
633
+ current_frame = end_frame
634
 
635
  return word_alignments
636
 
637
  processed = 0
638
  t_start = time.time()
639
+ last_heartbeat = time.time()
640
 
641
  while True:
642
+ # Send heartbeat every 30 seconds to show worker is alive
643
+ if time.time() - last_heartbeat > 30:
644
+ status_queue.put(("feat_heartbeat", gpu_id, processed))
645
+ last_heartbeat = time.time()
646
+
647
+ # Use timeout to allow heartbeat even when queue is empty
648
+ try:
649
+ item = feat_queue.get(timeout=5)
650
+ except:
651
+ continue # Timeout, send heartbeat and retry
652
+
653
  if item is None:
654
  break
655
 
 
657
  t0 = time.time()
658
 
659
  try:
660
+ # 1. Parallel Whisper encoding with GPU-accelerated resampling
661
  q_audios = [ad["q_audio"] for ad in audio_batch]
662
  with ThreadPoolExecutor(max_workers=whisper_workers) as ex:
663
  whisper_features = list(ex.map(process_whisper, q_audios))
664
 
665
+ # 2. SNAC encoding - GPU-batched with GPU resampling
666
  a_audios = [ad["a_audio"] for ad in audio_batch]
667
  all_tokens = []
668
 
 
672
 
673
  max_len = max(a.shape[0] for a in mini_audios)
674
  padded = [np.pad(a, (0, max_len - len(a))) for a in mini_audios]
675
+
676
+ # GPU-accelerated resampling for SNAC
677
  audios_24k = torch.stack([
678
+ torchaudio.functional.resample(
679
+ torch.from_numpy(a).to(device), 32000, 24000
680
+ ).cpu()
681
  for a in padded
682
  ])
683
 
 
689
 
690
  torch.cuda.synchronize()
691
 
692
+ # Periodic GPU memory cleanup every 100 batches to prevent fragmentation
693
+ if processed > 0 and processed % (100 * len(audio_batch)) == 0:
694
+ torch.cuda.empty_cache()
695
+ gc.collect()
696
+
697
  # 3. Build results with pre-computed text tokens and word alignments
698
  results = []
699
  for i, ad in enumerate(audio_batch):
 
722
  batch_time = time.time() - t0
723
  batch_rate = len(results) / batch_time if batch_time > 0 else 0
724
 
725
+ # Put results with timeout to prevent indefinite blocking
726
+ put_start = time.time()
727
+ max_put_attempts = 10
728
+ for attempt in range(max_put_attempts):
729
+ try:
730
+ result_queue.put((batch_idx, results), timeout=30)
731
+ break
732
+ except Exception as put_err:
733
+ if attempt < max_put_attempts - 1:
734
+ status_queue.put(("feat_warn", gpu_id, f"Queue full, retry {attempt+1}"))
735
+ time.sleep(1)
736
+ else:
737
+ status_queue.put(("feat_error", gpu_id, f"Failed to put results after {max_put_attempts} attempts"))
738
+ raise put_err
739
+
740
  processed += len(results)
741
  elapsed = time.time() - t_start
742
  status_queue.put(("feat", gpu_id, processed, processed/elapsed, batch_rate))
743
 
744
+ # Clear intermediate tensors to prevent memory accumulation
745
+ del whisper_features, all_tokens, results
746
+ if processed % 500 == 0: # More aggressive cleanup every 500 items
747
+ torch.cuda.empty_cache()
748
+ gc.collect()
749
+
750
  except Exception as e:
751
+ import traceback
752
+ error_msg = str(e)
753
+ status_queue.put(("feat_error", gpu_id, f"{error_msg}\n{traceback.format_exc()}"))
754
+
755
+ # Clear GPU memory aggressively
756
+ torch.cuda.empty_cache()
757
+ torch.cuda.synchronize()
758
+ gc.collect()
759
+
760
+ # If OOM, try to recover by reducing batch sizes
761
+ if "out of memory" in error_msg.lower() or "OOM" in error_msg:
762
+ status_queue.put(("feat_warn", gpu_id, "OOM detected, clearing memory..."))
763
+ time.sleep(2) # Give GPU time to recover
764
+ torch.cuda.empty_cache()
765
+
766
+ continue
767
 
768
  status_queue.put(("feat_done", gpu_id, processed))
769
 
 
778
 
779
  import argparse
780
  parser = argparse.ArgumentParser()
781
+ parser.add_argument("--count", "--num_samples", type=int, default=100, dest="count")
782
  parser.add_argument("--output", type=str, default="./data/dataset.pt")
783
  parser.add_argument("--gpus", type=int, default=NUM_GPUS)
784
+ parser.add_argument("--resume", action="store_true", help="Resume from existing checkpoint")
785
+ parser.add_argument("--checkpoint-interval", type=int, default=1000, help="Save checkpoint every N items")
786
+ parser.add_argument("--log-file", type=str, default=None, help="Log file path (default: output.log)")
787
  # Optional overrides for batch sizes (if not set, auto-calculated from VRAM)
788
  parser.add_argument("--tts-batch", type=int, default=None)
789
  parser.add_argument("--snac-batch", type=int, default=None)
 
792
 
793
  Path(args.output).parent.mkdir(parents=True, exist_ok=True)
794
 
795
+ # Setup logging (default to output path + .log)
796
+ log_file = args.log_file or (args.output + ".log")
797
+ setup_logging(log_file)
798
+ log(f"[Log] Writing to {log_file}")
799
+
800
  # Calculate batch sizes based on GPU VRAM
801
  vram_gb = get_gpu_vram_gb()
802
  batch_sizes = calculate_batch_sizes(vram_gb)
 
809
  if args.whisper_workers is not None:
810
  batch_sizes["whisper"] = args.whisper_workers
811
 
812
+ log("=" * 60)
813
+ log("Dataset Generator - Fully Async Pipeline")
814
+ log(f"Target: {args.count} items, GPUs: {args.gpus}")
815
  print_batch_config(batch_sizes, vram_gb)
816
+ log("=" * 60)
817
+
818
+ # Resume from checkpoint or output file if exists
819
+ checkpoint_path = args.output + ".checkpoint"
820
+ existing_items = []
821
+ start_count = 0
822
+
823
+ # Track resume state
824
+ resume_from_path = None
825
+
826
+ if args.resume:
827
+ # Check for existing data to resume from
828
+ # Priority: .new checkpoint (partial new items) + output file, or just output file
829
+
830
+ new_checkpoint = checkpoint_path + ".new"
831
+ new_items_count = 0
832
+ base_count = 0
833
+
834
+ # Check if we have new items checkpoint
835
+ if Path(new_checkpoint).exists():
836
+ try:
837
+ data = torch.load(new_checkpoint, map_location="cpu", weights_only=False, mmap=True)
838
+ new_items_count = len(data)
839
+ del data
840
+ log(f"[Resume] Found {new_items_count} new items in checkpoint")
841
+ except Exception as e:
842
+ log(f"[Resume] Failed to read {new_checkpoint}: {e}")
843
+
844
+ # Check output file for base items
845
+ if Path(args.output).exists():
846
+ try:
847
+ data = torch.load(args.output, map_location="cpu", weights_only=False, mmap=True)
848
+ base_count = len(data)
849
+ del data
850
+ resume_from_path = args.output
851
+ log(f"[Resume] Found {base_count} base items in {args.output}")
852
+ except Exception as e:
853
+ log(f"[Resume] Failed to read {args.output}: {e}")
854
+
855
+ # Total count is base + new
856
+ start_count = base_count + new_items_count
857
+ if start_count > 0:
858
+ log(f"[Resume] Total: {start_count} items ({base_count} base + {new_items_count} new), need {args.count - start_count} more")
859
+ else:
860
+ log("[Resume] No valid resume file found, starting fresh")
861
+
862
+ if start_count >= args.count:
863
+ log(f"[Resume] Already have {start_count} items, saving final...")
864
+ torch.save(existing_items[:args.count], args.output)
865
+ log(f"COMPLETE: {args.count} items saved to {args.output}")
866
+ return
867
+
868
+ remaining_count = args.count - start_count
869
+ log(f"[Main] Generating {remaining_count} new items...")
870
 
871
  total_start = time.time()
872
 
873
+ # Use regular mp.Queue with NO size limits to prevent deadlocks
874
+ # Workers will block on put() if queue is full, causing stalls
875
+ # Memory is managed by batch sizes instead
876
+ tts_queue = mp.Queue() # No maxsize - prevents TTS blocking
877
+ feat_queue = mp.Queue() # No maxsize - prevents Features blocking
878
+ result_queue = mp.Queue() # No maxsize - prevents result collection blocking
879
  status_queue = mp.Queue()
880
 
881
  workers = []
882
 
883
  # Get actual GPU count for worker assignment
884
  actual_num_gpus = get_num_gpus() if torch.cuda.is_available() else 1
 
885
 
886
+ # With 4+ GPUs, separate TTS and Features to avoid OOM
887
+ # TTS uses GPUs 0 to (N/2-1), Features uses GPUs (N/2) to (N-1)
888
+ if actual_num_gpus >= 4:
889
+ tts_gpus = actual_num_gpus // 2 # First half for TTS
890
+ feat_gpus = actual_num_gpus - tts_gpus # Second half for Features
891
+ tts_gpu_offset = 0
892
+ feat_gpu_offset = tts_gpus
893
+ log(f"\n[Main] Detected {actual_num_gpus} GPUs - Separating: TTS on GPUs 0-{tts_gpus-1}, Features on GPUs {feat_gpu_offset}-{actual_num_gpus-1}")
894
+ else:
895
+ tts_gpus = actual_num_gpus
896
+ feat_gpus = actual_num_gpus
897
+ tts_gpu_offset = 0
898
+ feat_gpu_offset = 0
899
+ log(f"\n[Main] Detected {actual_num_gpus} GPUs, sharing between TTS and Features")
900
+
901
+ # Adjust number of workers based on available GPUs
902
+ num_tts_workers = min(args.gpus, tts_gpus)
903
+ num_feat_workers = min(args.gpus, feat_gpus)
904
+ log(f"[Main] Spawning {num_tts_workers} TTS workers, {num_feat_workers} Features workers")
905
+
906
+ qa_proc = mp.Process(target=qa_producer, args=(remaining_count, tts_queue, batch_sizes["tts"], status_queue, num_tts_workers))
907
  qa_proc.start()
908
  workers.append(qa_proc)
909
 
910
+ for gpu_id in range(num_tts_workers):
911
+ p = mp.Process(target=tts_worker, args=(gpu_id, tts_queue, feat_queue, status_queue, batch_sizes, tts_gpus, tts_gpu_offset))
912
  p.start()
913
  workers.append(p)
914
 
915
+ for gpu_id in range(num_feat_workers):
916
+ p = mp.Process(target=features_worker, args=(gpu_id, feat_queue, result_queue, status_queue, batch_sizes, feat_gpus, feat_gpu_offset))
917
  p.start()
918
  workers.append(p)
919
 
920
+ log("[Pipeline] All workers started, monitoring...")
921
 
922
  results = {}
923
  tts_done_count = 0
 
926
  t0 = time.time()
927
  tts_ready = 0
928
  feat_ready = 0
929
+ expected_from_feat = {}
930
+ feat_queue_closed = False
931
+ last_checkpoint_count = 0
932
 
933
+ # Main loop with improved stall detection
934
  last_result_time = time.time()
935
+ last_status_time = time.time()
936
+ last_heartbeat_time = {f"tts_{i}": time.time() for i in range(num_tts_workers)}
937
+ last_heartbeat_time.update({f"feat_{i}": time.time() for i in range(num_feat_workers)})
938
+ stall_warning_shown = False
939
+ errors = []
940
+
941
  while True:
942
+ # Process ALL pending status messages
943
+ for _ in range(100): # Limit to prevent infinite loop
 
944
  try:
945
  msg = status_queue.get_nowait()
946
+ last_status_time = time.time()
947
  msg_type = msg[0]
948
 
949
  if msg_type == "tts_ready":
950
  tts_ready += 1
951
+ log(f"[TTS-GPU{msg[1]}] Ready ({tts_ready}/{num_tts_workers})")
952
  elif msg_type == "feat_ready":
953
  feat_ready += 1
954
+ log(f"[Features-GPU{msg[1]}] Ready ({feat_ready}/{num_feat_workers})")
955
  elif msg_type == "qa":
956
+ log(f"[Q&A] {msg[1]}/{msg[2]} | {msg[3]:.1f}/s")
957
  elif msg_type == "qa_done":
958
+ log(f"[Q&A] Done: {msg[1]} pairs, {msg[2]} batches")
959
  elif msg_type == "tts":
960
+ log(f"[TTS-GPU{msg[1]}] {msg[2]} items | avg {msg[3]:.1f}/s | batch {msg[4]:.1f}/s")
961
+ last_heartbeat_time[f"tts_{msg[1]}"] = time.time()
962
  elif msg_type == "tts_done":
963
  tts_done_count += 1
964
+ log(f"[TTS-GPU{msg[1]}] Done: {msg[2]} items")
965
  elif msg_type == "feat":
966
+ log(f"[Feat-GPU{msg[1]}] {msg[2]} items | avg {msg[3]:.1f}/s | batch {msg[4]:.1f}/s")
967
+ last_heartbeat_time[f"feat_{msg[1]}"] = time.time()
968
  elif msg_type == "feat_done":
969
  feat_done_count += 1
970
  expected_from_feat[msg[1]] = msg[2]
971
+ log(f"[Features-GPU{msg[1]}] Done: {msg[2]} items")
972
+ elif msg_type == "tts_heartbeat":
973
+ last_heartbeat_time[f"tts_{msg[1]}"] = time.time()
974
+ elif msg_type == "feat_heartbeat":
975
+ last_heartbeat_time[f"feat_{msg[1]}"] = time.time()
976
+ elif msg_type == "feat_warn":
977
+ log(f"[WARN] Features-GPU{msg[1]}: {msg[2]}")
978
  elif "error" in msg_type:
979
+ log(f"[Error] {msg}")
980
+ errors.append(msg)
981
  except:
982
  break
983
 
984
+ if tts_done_count >= num_tts_workers and not feat_queue_closed:
985
+ log(f"[Main] TTS done, waiting for features to finish...")
 
 
986
  feat_queue_closed = True
987
 
988
+ # Collect ALL available results (drain aggressively with non-blocking gets)
989
+ collected_this_round = 0
990
+ drain_start = time.time()
991
+ while time.time() - drain_start < 2.0: # Spend up to 2s draining
992
+ try:
993
+ batch_idx, items = result_queue.get_nowait()
994
+ results[batch_idx] = items
995
+ total_items += len(items)
996
+ collected_this_round += len(items)
997
+ last_result_time = time.time()
998
+ stall_warning_shown = False
999
+ except:
1000
+ # No more items immediately available, wait briefly then check again
1001
+ time.sleep(0.05)
1002
+ try:
1003
+ batch_idx, items = result_queue.get_nowait()
1004
+ results[batch_idx] = items
1005
+ total_items += len(items)
1006
+ collected_this_round += len(items)
1007
+ last_result_time = time.time()
1008
+ stall_warning_shown = False
1009
+ except:
1010
+ break # Queue truly empty
1011
+
1012
+ if collected_this_round > 0:
1013
  elapsed = time.time() - t0
1014
+ log(f"[Results] {total_items}/{remaining_count} | {total_items/elapsed:.1f}/s")
1015
+
1016
+ # Save checkpoint periodically (combine with any existing checkpoint)
1017
+ if total_items - last_checkpoint_count >= args.checkpoint_interval:
1018
+ log(f"[Checkpoint] Saving {total_items} new items...")
1019
+ # Collect items from this run
1020
+ items_this_run = []
1021
+ for i in sorted(results.keys()):
1022
+ items_this_run.extend(results[i])
1023
+
1024
+ # Load and combine with existing checkpoint if present
1025
+ checkpoint_new_path = checkpoint_path + ".new"
1026
+ all_checkpoint_items = []
1027
+ if Path(checkpoint_new_path).exists():
1028
+ try:
1029
+ prev_items = torch.load(checkpoint_new_path, map_location="cpu", weights_only=False)
1030
+ all_checkpoint_items = list(prev_items)
1031
+ del prev_items
1032
+ except:
1033
+ pass
1034
+
1035
+ # Only add items not already in checkpoint
1036
+ items_to_add = items_this_run[len(all_checkpoint_items):]
1037
+ all_checkpoint_items.extend(items_to_add)
1038
+
1039
+ torch.save(all_checkpoint_items, checkpoint_new_path)
1040
+ last_checkpoint_count = total_items
1041
+ log(f"[Checkpoint] Saved {len(all_checkpoint_items)} total to {checkpoint_new_path}")
1042
 
1043
  # Check exit conditions
1044
+ if total_items >= remaining_count:
1045
+ log(f"[Main] Target reached: {total_items}/{remaining_count}")
1046
  break
1047
 
1048
+ # If all feature workers done, drain remaining results
1049
+ if feat_done_count >= num_feat_workers:
1050
+ log(f"[Main] All workers done, draining queue...")
 
1051
  drain_start = time.time()
1052
+ while time.time() - drain_start < 60.0:
1053
  try:
1054
  batch_idx, items = result_queue.get(timeout=0.5)
1055
  results[batch_idx] = items
1056
  total_items += len(items)
1057
+ log(f"[Results] {total_items}/{remaining_count} | (drained)")
1058
  except:
1059
+ time.sleep(0.2)
1060
+ try:
1061
+ batch_idx, items = result_queue.get_nowait()
1062
+ results[batch_idx] = items
1063
+ total_items += len(items)
1064
+ log(f"[Results] {total_items}/{remaining_count} | (drained)")
1065
+ except:
1066
  break
1067
  break
1068
 
1069
+ # Check if workers are still alive and responding
1070
+ alive_workers = sum(1 for p in workers if p.is_alive())
1071
+ time_since_result = time.time() - last_result_time
1072
+ time_since_status = time.time() - last_status_time
1073
+
1074
+ # Check heartbeats - detect stuck workers
1075
+ stuck_workers = []
1076
+ for worker_id, last_hb in last_heartbeat_time.items():
1077
+ if time.time() - last_hb > 120: # No heartbeat for 2 minutes
1078
+ stuck_workers.append(worker_id)
1079
+
1080
+ if time_since_result > 30 and not stall_warning_shown:
1081
+ queue_size = 0
1082
+ try:
1083
+ queue_size = result_queue.qsize()
1084
+ except:
1085
+ pass
1086
+ log(f"[WARN] No results for 30s, {alive_workers} workers alive, queue ~{queue_size} items")
1087
+ if stuck_workers:
1088
+ log(f"[WARN] Stuck workers (no heartbeat >2min): {stuck_workers}")
1089
+ stall_warning_shown = True
1090
+
1091
+ # Save checkpoint on stall detection (combine with existing checkpoint)
1092
+ if time_since_result > 60 and total_items > last_checkpoint_count:
1093
+ log(f"[Checkpoint] Stall detected, saving {total_items} items...")
1094
+ items_this_run = []
1095
+ for i in sorted(results.keys()):
1096
+ items_this_run.extend(results[i])
1097
+
1098
+ # Load and combine with existing checkpoint
1099
+ checkpoint_new_path = checkpoint_path + ".new"
1100
+ all_checkpoint_items = []
1101
+ if Path(checkpoint_new_path).exists():
1102
+ try:
1103
+ prev_items = torch.load(checkpoint_new_path, map_location="cpu", weights_only=False)
1104
+ all_checkpoint_items = list(prev_items)
1105
+ del prev_items
1106
+ except:
1107
+ pass
1108
+
1109
+ items_to_add = items_this_run[len(all_checkpoint_items):]
1110
+ all_checkpoint_items.extend(items_to_add)
1111
+ torch.save(all_checkpoint_items, checkpoint_new_path)
1112
+ last_checkpoint_count = total_items
1113
+
1114
+ # Exit if truly stalled - but be smarter about it
1115
+ # Only exit if: no results for 3min AND no heartbeats AND workers dead
1116
+ all_workers_stuck = len(stuck_workers) >= (num_tts_workers + num_feat_workers)
1117
+ if time_since_result > 180 and all_workers_stuck:
1118
+ log(f"[WARN] All workers stuck, stopping with {total_items} items")
1119
+ log(f"[WARN] Errors encountered: {len(errors)}")
1120
+ for err in errors[-5:]: # Show last 5 errors
1121
+ log(f" {err}")
1122
+ break
1123
+
1124
+ if time_since_result > 600:
1125
+ log(f"[WARN] No progress for 10min, stopping with {total_items} items")
1126
  break
1127
 
1128
+ # Save checkpoint before cleanup (combine with existing checkpoint)
1129
+ if total_items > last_checkpoint_count:
1130
+ log(f"[Checkpoint] Final save before cleanup: {total_items} items...")
1131
+ items_this_run = []
1132
+ for i in sorted(results.keys()):
1133
+ items_this_run.extend(results[i])
1134
+
1135
+ # Load and combine with existing checkpoint
1136
+ checkpoint_new_path = checkpoint_path + ".new"
1137
+ all_checkpoint_items = []
1138
+ if Path(checkpoint_new_path).exists():
1139
+ try:
1140
+ prev_items = torch.load(checkpoint_new_path, map_location="cpu", weights_only=False)
1141
+ all_checkpoint_items = list(prev_items)
1142
+ del prev_items
1143
+ except:
1144
+ pass
1145
+
1146
+ items_to_add = items_this_run[len(all_checkpoint_items):]
1147
+ all_checkpoint_items.extend(items_to_add)
1148
+ torch.save(all_checkpoint_items, checkpoint_new_path)
1149
+ last_checkpoint_count = total_items
1150
+
1151
  # Wait for workers to finish
1152
+ log("[Main] Waiting for workers to finish...")
1153
  for p in workers:
1154
  p.join(timeout=5)
1155
  if p.is_alive():
1156
  p.terminate()
1157
 
1158
+ # Final drain
1159
+ for _ in range(100):
1160
  try:
1161
  batch_idx, items = result_queue.get_nowait()
1162
  results[batch_idx] = items
1163
  total_items += len(items)
 
1164
  except:
1165
  break
1166
 
1167
+ # Collect new results from this run
1168
+ new_items_this_run = []
1169
  for i in sorted(results.keys()):
1170
+ new_items_this_run.extend(results[i])
1171
+
1172
+ log(f"[Main] Generated {len(new_items_this_run)} new items this run")
1173
 
1174
+ # Load any previously checkpointed new items and combine with this run's items
1175
+ new_checkpoint = checkpoint_path + ".new"
1176
+ new_items = []
1177
+
1178
+ if Path(new_checkpoint).exists():
1179
+ try:
1180
+ prev_new = torch.load(new_checkpoint, map_location="cpu", weights_only=False)
1181
+ new_items = list(prev_new)
1182
+ log(f"[Main] Loaded {len(new_items)} items from previous checkpoint")
1183
+ del prev_new
1184
+ except Exception as e:
1185
+ log(f"[Main] Failed to load previous checkpoint: {e}")
1186
+
1187
+ # Add this run's items to the checkpoint items
1188
+ new_items.extend(new_items_this_run)
1189
+ log(f"[Main] Total new items: {len(new_items)} (checkpoint: {len(new_items) - len(new_items_this_run)}, this run: {len(new_items_this_run)})")
1190
+
1191
+ total_new = len(new_items)
1192
+
1193
+ if total_new == 0 and (not resume_from_path or start_count == 0):
1194
+ log("[ERROR] No items collected! Check worker logs.")
1195
  sys.exit(1)
1196
 
1197
+ # Combine with base items if resuming
1198
+ if resume_from_path and Path(resume_from_path).exists():
1199
+ log(f"[Main] Loading base items from {resume_from_path}...")
1200
+ base_data = torch.load(resume_from_path, map_location="cpu", weights_only=False, mmap=True)
1201
+ base_count = len(base_data)
1202
+
1203
+ # Calculate how many base items to keep
1204
+ items_needed_from_base = min(base_count, args.count - total_new)
1205
+ log(f"[Main] Combining {items_needed_from_base} base + {total_new} new items...")
1206
+
1207
+ final_items = list(base_data[:items_needed_from_base]) + new_items
1208
+ del base_data
1209
+ else:
1210
+ final_items = new_items
1211
+
1212
+ # Trim to target count
1213
+ final_items = final_items[:args.count]
1214
+
1215
+ # Save final dataset
1216
+ log(f"[Main] Saving {len(final_items)} items to {args.output}...")
1217
+ torch.save(final_items, args.output)
1218
+
1219
+ # Remove checkpoint files if complete
1220
+ if len(final_items) >= args.count:
1221
+ for cp in [checkpoint_path, checkpoint_path + ".new"]:
1222
+ if Path(cp).exists():
1223
+ Path(cp).unlink()
1224
+ log(f"[Cleanup] Removed {cp}")
1225
 
1226
  total_time = time.time() - total_start
1227
+ log("\n" + "=" * 60)
1228
+ log(f"COMPLETE: {len(final_items)} items saved to {args.output}")
1229
+ if start_count > 0:
1230
+ log(f" (resumed from {start_count}, added {len(final_items) - start_count} new)")
1231
+ log(f"Total time: {total_time:.1f}s ({total_time/60:.1f}m)")
1232
+ log(f"Throughput: {(len(final_items) - start_count)/total_time:.2f} items/s")
1233
+ log("=" * 60)
1234
 
1235
 
1236
  if __name__ == "__main__":
datasets/validate_dataset.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Dataset Validator - Validates the generated dataset for training.
4
+
5
+ Checks:
6
+ 1. Structure: list of dicts with required fields
7
+ 2. Whisper features: shape [seq_len, 1280]
8
+ 3. SNAC tokens: multiple of 7, valid range
9
+ 4. Optional fields: text, answer, text_tokens, word_alignments
10
+ 5. Statistics and distribution
11
+ """
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+ from collections import defaultdict
17
+
18
+ import torch
19
+ import numpy as np
20
+
21
+
22
+ # SNAC token constants (from CLAUDE.md)
23
+ SNAC_BASE = 128266
24
+ SNAC_LAYERS = 7
25
+ SNAC_VOCAB_PER_LAYER = 4096
26
+ WHISPER_DIM = 1280
27
+
28
+
29
+ def validate_sample(idx: int, sample: dict, verbose: bool = False) -> tuple[bool, list[str]]:
30
+ """Validate a single sample. Returns (is_valid, list of errors)."""
31
+ errors = []
32
+ warnings = []
33
+
34
+ # Check required fields
35
+ if "whisper_features" not in sample:
36
+ errors.append("Missing 'whisper_features'")
37
+ if "snac_tokens" not in sample:
38
+ errors.append("Missing 'snac_tokens'")
39
+
40
+ if errors:
41
+ return False, errors
42
+
43
+ # Validate whisper_features
44
+ wf = sample["whisper_features"]
45
+ if not isinstance(wf, torch.Tensor):
46
+ errors.append(f"whisper_features should be Tensor, got {type(wf).__name__}")
47
+ else:
48
+ if wf.dim() != 2:
49
+ errors.append(f"whisper_features should be 2D [seq_len, 1280], got {wf.dim()}D")
50
+ elif wf.shape[1] != WHISPER_DIM:
51
+ errors.append(f"whisper_features dim should be {WHISPER_DIM}, got {wf.shape[1]}")
52
+ if wf.shape[0] == 0:
53
+ errors.append("whisper_features has 0 length")
54
+ if torch.isnan(wf).any():
55
+ errors.append("whisper_features contains NaN values")
56
+ if torch.isinf(wf).any():
57
+ errors.append("whisper_features contains Inf values")
58
+
59
+ # Validate snac_tokens
60
+ st = sample["snac_tokens"]
61
+ if not isinstance(st, torch.Tensor):
62
+ errors.append(f"snac_tokens should be Tensor, got {type(st).__name__}")
63
+ else:
64
+ if st.dim() != 1:
65
+ errors.append(f"snac_tokens should be 1D, got {st.dim()}D")
66
+ if len(st) == 0:
67
+ errors.append("snac_tokens has 0 length")
68
+ elif len(st) % SNAC_LAYERS != 0:
69
+ errors.append(f"snac_tokens length ({len(st)}) not multiple of {SNAC_LAYERS}")
70
+
71
+ # Check token range (raw tokens before offset, should be 0-4095)
72
+ if len(st) > 0:
73
+ min_tok = st.min().item()
74
+ max_tok = st.max().item()
75
+ # Tokens could be raw (0-4095) or with offset applied (128266+)
76
+ if max_tok < SNAC_BASE:
77
+ # Raw tokens
78
+ if min_tok < 0 or max_tok >= SNAC_VOCAB_PER_LAYER:
79
+ warnings.append(f"snac_tokens range [{min_tok}, {max_tok}] outside [0, {SNAC_VOCAB_PER_LAYER-1}]")
80
+ else:
81
+ # Tokens with offset
82
+ expected_max = SNAC_BASE + (SNAC_LAYERS * SNAC_VOCAB_PER_LAYER)
83
+ if min_tok < SNAC_BASE or max_tok >= expected_max:
84
+ warnings.append(f"snac_tokens with offset range [{min_tok}, {max_tok}] unexpected")
85
+
86
+ # Validate optional fields
87
+ if "text" in sample and not isinstance(sample["text"], str):
88
+ warnings.append(f"text should be str, got {type(sample['text']).__name__}")
89
+
90
+ if "answer" in sample and not isinstance(sample["answer"], str):
91
+ warnings.append(f"answer should be str, got {type(sample['answer']).__name__}")
92
+
93
+ if "text_tokens" in sample:
94
+ tt = sample["text_tokens"]
95
+ if not isinstance(tt, torch.Tensor):
96
+ warnings.append(f"text_tokens should be Tensor, got {type(tt).__name__}")
97
+ elif tt.dim() != 1:
98
+ warnings.append(f"text_tokens should be 1D, got {tt.dim()}D")
99
+
100
+ if "word_alignments" in sample:
101
+ wa = sample["word_alignments"]
102
+ if not isinstance(wa, list):
103
+ warnings.append(f"word_alignments should be list, got {type(wa).__name__}")
104
+
105
+ if verbose and warnings:
106
+ for w in warnings:
107
+ print(f" [WARN] Sample {idx}: {w}")
108
+
109
+ return len(errors) == 0, errors
110
+
111
+
112
+ def compute_statistics(dataset: list) -> dict:
113
+ """Compute dataset statistics."""
114
+ stats = {
115
+ "total_samples": len(dataset),
116
+ "whisper_lengths": [],
117
+ "snac_lengths": [],
118
+ "snac_frames": [],
119
+ "has_text": 0,
120
+ "has_answer": 0,
121
+ "has_text_tokens": 0,
122
+ "has_word_alignments": 0,
123
+ "text_lengths": [],
124
+ "answer_lengths": [],
125
+ }
126
+
127
+ for sample in dataset:
128
+ if "whisper_features" in sample and isinstance(sample["whisper_features"], torch.Tensor):
129
+ stats["whisper_lengths"].append(sample["whisper_features"].shape[0])
130
+
131
+ if "snac_tokens" in sample and isinstance(sample["snac_tokens"], torch.Tensor):
132
+ length = len(sample["snac_tokens"])
133
+ stats["snac_lengths"].append(length)
134
+ stats["snac_frames"].append(length // SNAC_LAYERS)
135
+
136
+ if "text" in sample:
137
+ stats["has_text"] += 1
138
+ if isinstance(sample["text"], str):
139
+ stats["text_lengths"].append(len(sample["text"]))
140
+
141
+ if "answer" in sample:
142
+ stats["has_answer"] += 1
143
+ if isinstance(sample["answer"], str):
144
+ stats["answer_lengths"].append(len(sample["answer"]))
145
+
146
+ if "text_tokens" in sample:
147
+ stats["has_text_tokens"] += 1
148
+
149
+ if "word_alignments" in sample:
150
+ stats["has_word_alignments"] += 1
151
+
152
+ return stats
153
+
154
+
155
+ def print_statistics(stats: dict):
156
+ """Print dataset statistics."""
157
+ print("\n" + "=" * 60)
158
+ print("DATASET STATISTICS")
159
+ print("=" * 60)
160
+
161
+ print(f"\nTotal samples: {stats['total_samples']}")
162
+
163
+ # Whisper features
164
+ if stats["whisper_lengths"]:
165
+ wl = np.array(stats["whisper_lengths"])
166
+ print(f"\nWhisper features length:")
167
+ print(f" Min: {wl.min()}, Max: {wl.max()}, Mean: {wl.mean():.1f}, Std: {wl.std():.1f}")
168
+
169
+ # SNAC tokens
170
+ if stats["snac_lengths"]:
171
+ sl = np.array(stats["snac_lengths"])
172
+ sf = np.array(stats["snac_frames"])
173
+ print(f"\nSNAC tokens:")
174
+ print(f" Tokens - Min: {sl.min()}, Max: {sl.max()}, Mean: {sl.mean():.1f}")
175
+ print(f" Frames - Min: {sf.min()}, Max: {sf.max()}, Mean: {sf.mean():.1f}")
176
+
177
+ # Duration estimate (24kHz, 512 samples per frame = 21.3ms per frame)
178
+ duration_sec = sf * 0.0213
179
+ print(f" Duration - Min: {duration_sec.min():.1f}s, Max: {duration_sec.max():.1f}s, Mean: {duration_sec.mean():.1f}s")
180
+
181
+ # Optional fields
182
+ print(f"\nOptional fields present:")
183
+ print(f" text: {stats['has_text']}/{stats['total_samples']} ({100*stats['has_text']/stats['total_samples']:.1f}%)")
184
+ print(f" answer: {stats['has_answer']}/{stats['total_samples']} ({100*stats['has_answer']/stats['total_samples']:.1f}%)")
185
+ print(f" text_tokens: {stats['has_text_tokens']}/{stats['total_samples']} ({100*stats['has_text_tokens']/stats['total_samples']:.1f}%)")
186
+ print(f" word_alignments: {stats['has_word_alignments']}/{stats['total_samples']} ({100*stats['has_word_alignments']/stats['total_samples']:.1f}%)")
187
+
188
+ # Text lengths
189
+ if stats["text_lengths"]:
190
+ tl = np.array(stats["text_lengths"])
191
+ print(f"\nText (question) length (chars):")
192
+ print(f" Min: {tl.min()}, Max: {tl.max()}, Mean: {tl.mean():.1f}")
193
+
194
+ if stats["answer_lengths"]:
195
+ al = np.array(stats["answer_lengths"])
196
+ print(f"\nAnswer length (chars):")
197
+ print(f" Min: {al.min()}, Max: {al.max()}, Mean: {al.mean():.1f}")
198
+
199
+
200
+ def validate_dataset(path: str, max_samples: int = None, verbose: bool = False) -> bool:
201
+ """Validate the dataset file."""
202
+ print(f"\nValidating: {path}")
203
+ print("=" * 60)
204
+
205
+ # Check file exists
206
+ if not Path(path).exists():
207
+ print(f"[ERROR] File not found: {path}")
208
+ return False
209
+
210
+ # Load dataset
211
+ print("Loading dataset...")
212
+ try:
213
+ dataset = torch.load(path, map_location="cpu", weights_only=False)
214
+ except Exception as e:
215
+ print(f"[ERROR] Failed to load dataset: {e}")
216
+ return False
217
+
218
+ # Check type
219
+ if not isinstance(dataset, list):
220
+ print(f"[ERROR] Dataset should be a list, got {type(dataset).__name__}")
221
+ return False
222
+
223
+ total = len(dataset)
224
+ print(f"Loaded {total} samples")
225
+
226
+ if total == 0:
227
+ print("[ERROR] Dataset is empty")
228
+ return False
229
+
230
+ # Validate samples
231
+ if max_samples and max_samples < total:
232
+ print(f"Validating first {max_samples} samples...")
233
+ samples_to_check = dataset[:max_samples]
234
+ else:
235
+ print(f"Validating all {total} samples...")
236
+ samples_to_check = dataset
237
+
238
+ valid_count = 0
239
+ error_counts = defaultdict(int)
240
+
241
+ for idx, sample in enumerate(samples_to_check):
242
+ if not isinstance(sample, dict):
243
+ print(f"[ERROR] Sample {idx}: should be dict, got {type(sample).__name__}")
244
+ error_counts["not_dict"] += 1
245
+ continue
246
+
247
+ is_valid, errors = validate_sample(idx, sample, verbose=verbose)
248
+ if is_valid:
249
+ valid_count += 1
250
+ else:
251
+ for err in errors:
252
+ error_counts[err] += 1
253
+ if verbose:
254
+ print(f"[ERROR] Sample {idx}: {err}")
255
+
256
+ # Summary
257
+ checked = len(samples_to_check)
258
+ invalid = checked - valid_count
259
+
260
+ print(f"\n{'=' * 60}")
261
+ print("VALIDATION SUMMARY")
262
+ print("=" * 60)
263
+ print(f"Samples checked: {checked}/{total}")
264
+ print(f"Valid: {valid_count} ({100*valid_count/checked:.1f}%)")
265
+ print(f"Invalid: {invalid} ({100*invalid/checked:.1f}%)")
266
+
267
+ if error_counts:
268
+ print(f"\nError breakdown:")
269
+ for err, count in sorted(error_counts.items(), key=lambda x: -x[1]):
270
+ print(f" {count:5d}x {err}")
271
+
272
+ # Compute and print statistics
273
+ stats = compute_statistics(samples_to_check)
274
+ print_statistics(stats)
275
+
276
+ # Check a sample for inspection
277
+ if verbose and valid_count > 0:
278
+ print(f"\n{'=' * 60}")
279
+ print("SAMPLE INSPECTION (first valid sample)")
280
+ print("=" * 60)
281
+ for idx, sample in enumerate(samples_to_check):
282
+ is_valid, _ = validate_sample(idx, sample)
283
+ if is_valid:
284
+ print(f"Sample {idx}:")
285
+ for key, value in sample.items():
286
+ if isinstance(value, torch.Tensor):
287
+ print(f" {key}: Tensor {value.shape} {value.dtype}")
288
+ elif isinstance(value, str):
289
+ preview = value[:100] + "..." if len(value) > 100 else value
290
+ print(f" {key}: '{preview}'")
291
+ elif isinstance(value, list):
292
+ print(f" {key}: list[{len(value)}]")
293
+ else:
294
+ print(f" {key}: {type(value).__name__}")
295
+ break
296
+
297
+ print("\n" + "=" * 60)
298
+ if invalid == 0:
299
+ print("RESULT: PASSED - All samples valid")
300
+ return True
301
+ else:
302
+ print(f"RESULT: FAILED - {invalid} invalid samples")
303
+ return False
304
+
305
+
306
+ def main():
307
+ parser = argparse.ArgumentParser(description="Validate dataset for training")
308
+ parser.add_argument("--path", type=str, required=True, help="Path to dataset .pt file")
309
+ parser.add_argument("--max-samples", type=int, default=None, help="Max samples to validate (default: all)")
310
+ parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
311
+ args = parser.parse_args()
312
+
313
+ success = validate_dataset(args.path, args.max_samples, args.verbose)
314
+ sys.exit(0 if success else 1)
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
passo0_setup.py CHANGED
@@ -5,7 +5,13 @@ Instala todas as dependências para:
5
  - Geração de dataset (Soprano TTS, Whisper, SNAC, NeMo NFA)
6
  - Treinamento do modelo Speech-to-Speech
7
 
8
- Suporta: H200, H100, A100, RTX 4090, RTX 5090, etc.
 
 
 
 
 
 
9
 
10
  Usage:
11
  python passo0_setup.py [--skip_test]
@@ -51,13 +57,15 @@ def get_gpu_info():
51
 
52
  def needs_cuda_128(gpu_name, compute_cap):
53
  """Check if GPU needs CUDA 12.8+ (Hopper/Blackwell architecture)."""
54
- # H100, H200 = Hopper (sm_90), RTX 50xx = Blackwell (sm_100)
55
- hopper_blackwell = ["H100", "H200", "RTX 50", "B100", "B200"]
 
56
  if any(arch in gpu_name for arch in hopper_blackwell):
57
  return True
58
- # Compute capability 9.0+ needs CUDA 12.8
59
  try:
60
- if float(compute_cap) >= 9.0:
 
61
  return True
62
  except:
63
  pass
@@ -85,13 +93,16 @@ def main():
85
  run("apt-get update -qq && apt-get install -y -qq espeak-ng espeak libsndfile1 ffmpeg git wget curl build-essential sox libsox-fmt-all", check=False)
86
 
87
  # 2. PyTorch (version depends on GPU architecture)
88
- log("\n[2/7] PyTorch...")
 
89
  if use_cuda_128:
90
  # Hopper (H100/H200) and Blackwell (RTX 50xx) need CUDA 12.8
91
- run("pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 --index-url https://download.pytorch.org/whl/cu128 -q")
 
92
  else:
93
  # Ampere (A100, RTX 30xx) and Ada (RTX 40xx) can use CUDA 12.4
94
- run("pip install torch==2.5.0 torchaudio==2.5.0 torchvision==0.20.0 --index-url https://download.pytorch.org/whl/cu124 -q")
 
95
 
96
  # 3. Core packages
97
  log("\n[3/7] Core packages...")
@@ -116,11 +127,24 @@ def main():
116
  # We use native Whisper from transformers (already installed above)
117
  # No whisperx/ctranslate2 needed - they don't support H200 (sm_90)
118
 
119
- # 6. Soprano TTS + lmdeploy (fastest backend - 2000x realtime)
120
- log("\n[6/8] Soprano TTS + lmdeploy...")
 
 
 
 
121
  run("pip install soprano-tts -q")
122
- # lmdeploy is the fastest backend for Soprano TTS (2000x realtime vs ~10x for transformers)
123
- run("pip install lmdeploy -q")
 
 
 
 
 
 
 
 
 
124
 
125
  # 7. Create directories
126
  log("\n[7/8] Creating directories...")
 
5
  - Geração de dataset (Soprano TTS, Whisper, SNAC, NeMo NFA)
6
  - Treinamento do modelo Speech-to-Speech
7
 
8
+ PyTorch 2.7.0 com suporte oficial a Blackwell (sm_120)
9
+
10
+ Suporta:
11
+ - Blackwell: RTX 5090, 5080, 5070, B100, B200 (sm_120) - CUDA 12.8
12
+ - Hopper: H100, H200 (sm_90) - CUDA 12.8
13
+ - Ada: RTX 4090, 4080, L40S (sm_89) - CUDA 12.4
14
+ - Ampere: A100, RTX 3090 (sm_80/86) - CUDA 12.4
15
 
16
  Usage:
17
  python passo0_setup.py [--skip_test]
 
57
 
58
  def needs_cuda_128(gpu_name, compute_cap):
59
  """Check if GPU needs CUDA 12.8+ (Hopper/Blackwell architecture)."""
60
+ # H100, H200 = Hopper (sm_90)
61
+ # RTX 50xx, B100, B200 = Blackwell (sm_120) - requires PyTorch 2.7+
62
+ hopper_blackwell = ["H100", "H200", "RTX 50", "5090", "5080", "5070", "B100", "B200"]
63
  if any(arch in gpu_name for arch in hopper_blackwell):
64
  return True
65
+ # Compute capability 9.0+ (Hopper) or 12.0+ (Blackwell) needs CUDA 12.8
66
  try:
67
+ cap = float(compute_cap)
68
+ if cap >= 9.0: # sm_90 (Hopper) or sm_120 (Blackwell)
69
  return True
70
  except:
71
  pass
 
93
  run("apt-get update -qq && apt-get install -y -qq espeak-ng espeak libsndfile1 ffmpeg git wget curl build-essential sox libsox-fmt-all", check=False)
94
 
95
  # 2. PyTorch (version depends on GPU architecture)
96
+ # PyTorch 2.7.0 has official Blackwell (sm_120) support
97
+ log("\n[2/7] PyTorch 2.7.0 (Blackwell compatible)...")
98
  if use_cuda_128:
99
  # Hopper (H100/H200) and Blackwell (RTX 50xx) need CUDA 12.8
100
+ log(" Installing PyTorch 2.7.0 with CUDA 12.8 (Blackwell/Hopper support)")
101
+ run("pip install torch==2.7.0 torchaudio==2.7.0 torchvision==0.22.0 --index-url https://download.pytorch.org/whl/cu128 -q")
102
  else:
103
  # Ampere (A100, RTX 30xx) and Ada (RTX 40xx) can use CUDA 12.4
104
+ log(" Installing PyTorch 2.7.0 with CUDA 12.4 (Ampere/Ada)")
105
+ run("pip install torch==2.7.0 torchaudio==2.7.0 torchvision==0.22.0 --index-url https://download.pytorch.org/whl/cu124 -q")
106
 
107
  # 3. Core packages
108
  log("\n[3/7] Core packages...")
 
127
  # We use native Whisper from transformers (already installed above)
128
  # No whisperx/ctranslate2 needed - they don't support H200 (sm_90)
129
 
130
+ # 6. Soprano TTS 80M (ultra-lightweight, 2000x realtime)
131
+ # See: https://github.com/ekwek1/soprano
132
+ log("\n[6/8] Soprano TTS 80M...")
133
+
134
+ # Install soprano-tts with lmdeploy for fastest inference
135
+ # lmdeploy provides 2000x realtime speed vs ~10x for transformers backend
136
  run("pip install soprano-tts -q")
137
+
138
+ # lmdeploy doesn't support Blackwell (RTX 50xx) yet, so only install on supported GPUs
139
+ blackwell_gpus = ["RTX 50", "5090", "5080", "5070", "B100", "B200"]
140
+ is_blackwell = any(arch in gpu_name for arch in blackwell_gpus)
141
+
142
+ if is_blackwell:
143
+ log(" Blackwell GPU detected - skipping lmdeploy (not supported yet)")
144
+ log(" Soprano will use transformers backend (slower but compatible)")
145
+ else:
146
+ log(" Installing lmdeploy for 2000x realtime speed...")
147
+ run("pip install lmdeploy -q")
148
 
149
  # 7. Create directories
150
  log("\n[7/8] Creating directories...")
passo2_finetune_stage1.py CHANGED
@@ -8,862 +8,213 @@ Based on IST-LM paper combined with LLaMA-Omni 2 staging:
8
  - LLM is completely frozen (adapter gets a "head start")
9
 
10
  Usage:
11
- python finetune_stage1.py --data data.pt --epochs 2
12
 
13
  Next: Stage 2 trains Adapter + LoRA together
14
  """
15
 
16
- import os
17
- import sys
18
  import argparse
19
  import torch
20
- import torch.nn as nn
21
- import torch.nn.functional as F
22
- from torch.utils.data import Dataset, DataLoader, ConcatDataset
23
- from torch.optim.lr_scheduler import CosineAnnealingLR
24
- from transformers import AutoModelForCausalLM, AutoTokenizer
25
- from accelerate import Accelerator
26
- from accelerate.utils import set_seed
27
- from huggingface_hub import login
28
- from tqdm import tqdm
29
- import threading
30
-
31
- # ============================================================
32
- # Config
33
- # ============================================================
34
- torch.backends.cuda.matmul.allow_tf32 = True
35
- torch.backends.cudnn.allow_tf32 = True
36
- torch.backends.cudnn.benchmark = True
37
- torch.set_float32_matmul_precision('high')
38
-
39
- # SNAC token offsets for Orpheus
40
- SNAC_BASE_OFFSET = 128266
41
- EOS_TOKEN = 128009
42
-
43
-
44
- def log(msg):
45
- print(msg)
46
- sys.stdout.flush()
47
-
48
-
49
- def apply_snac_offset(token_idx, position):
50
- """Apply position-based offset to SNAC token.
51
- If token is already offset (>= SNAC_BASE_OFFSET), return as-is.
52
- """
53
- if int(token_idx) >= SNAC_BASE_OFFSET:
54
- # Already has offset applied
55
- return int(token_idx)
56
- offset = SNAC_BASE_OFFSET + (position % 7) * 4096
57
- return int(token_idx) + offset
58
 
59
 
60
- def get_text_ratio(global_step, decay_steps=300, initial_ratio=0.9, min_ratio=0.0):
61
  """
62
- IST-LM: Start with 90% text, decrease by 0.1 every 300 steps.
 
 
 
63
  """
64
- num_decays = global_step // decay_steps
65
- text_ratio = initial_ratio - (num_decays * 0.1)
66
- return max(min_ratio, text_ratio)
67
-
68
-
69
- # ============================================================
70
- # Async Checkpoint Saving
71
- # ============================================================
72
- _save_threads = []
73
-
74
- def save_checkpoint_async(state_dict, path, is_main=True):
75
- global _save_threads
76
- _save_threads = [t for t in _save_threads if t.is_alive()]
77
-
78
- def copy_to_cpu(obj):
79
- if isinstance(obj, torch.Tensor):
80
- return obj.detach().cpu().clone()
81
- elif isinstance(obj, dict):
82
- return {k: copy_to_cpu(v) for k, v in obj.items()}
83
- return obj
84
-
85
- state_copy = copy_to_cpu(state_dict)
86
-
87
- def _save():
88
- try:
89
- torch.save(state_copy, path)
90
- if is_main:
91
- log(f"[ASYNC] Saved: {path}")
92
- except Exception as e:
93
- if is_main:
94
- log(f"[ASYNC] Error: {e}")
95
-
96
- thread = threading.Thread(target=_save, daemon=True)
97
- thread.start()
98
- _save_threads.append(thread)
99
-
100
-
101
- def wait_for_checkpoints():
102
- global _save_threads
103
- for t in _save_threads:
104
- t.join()
105
- _save_threads = []
106
-
107
-
108
- # ============================================================
109
- # GPU Auto-Detection (Multi-backend: CUDA, ROCm, MPS, XPU)
110
- # ============================================================
111
- def auto_detect_gpu_config():
112
- """Detect GPU and return optimal batch size config."""
113
- vram_gb = 0
114
- gpu_name = "Unknown"
115
-
116
- # Try CUDA (NVIDIA)
117
- if torch.cuda.is_available():
118
- try:
119
- props = torch.cuda.get_device_properties(0)
120
- vram_gb = props.total_memory // (1024**3)
121
- gpu_name = props.name
122
- except:
123
- pass
124
-
125
- # Try MPS (Apple Silicon)
126
- elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
127
- gpu_name = "Apple Silicon (MPS)"
128
- # Apple Silicon unified memory - estimate based on system
129
- try:
130
- import subprocess
131
- result = subprocess.run(['sysctl', '-n', 'hw.memsize'], capture_output=True, text=True)
132
- total_mem = int(result.stdout.strip()) // (1024**3)
133
- vram_gb = total_mem // 2 # Assume half for GPU
134
- except:
135
- vram_gb = 8 # Conservative default
136
-
137
- # Try ROCm (AMD) - uses same API as CUDA
138
- elif hasattr(torch, 'hip') or os.environ.get('ROCM_HOME'):
139
- try:
140
- if torch.cuda.is_available(): # ROCm exposes as CUDA
141
- props = torch.cuda.get_device_properties(0)
142
- vram_gb = props.total_memory // (1024**3)
143
- gpu_name = f"AMD {props.name}"
144
- except:
145
- gpu_name = "AMD ROCm"
146
- vram_gb = 16
147
-
148
- # Fallback: try nvidia-smi
149
- if vram_gb == 0:
150
- try:
151
- import subprocess
152
- result = subprocess.run(
153
- ['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],
154
- capture_output=True, text=True
155
- )
156
- lines = result.stdout.strip().split('\n')
157
- gpu_name, vram_mb = lines[0].split(', ')
158
- vram_gb = int(vram_mb) // 1024
159
- except:
160
- pass
161
-
162
- # Determine batch size based on VRAM
163
- if vram_gb >= 150:
164
- return {"name": gpu_name, "batch_size": 8, "grad_accum": 4, "vram_gb": vram_gb}
165
- elif vram_gb >= 80:
166
- return {"name": gpu_name, "batch_size": 6, "grad_accum": 5, "vram_gb": vram_gb}
167
- elif vram_gb >= 35:
168
- return {"name": gpu_name, "batch_size": 4, "grad_accum": 8, "vram_gb": vram_gb}
169
- elif vram_gb >= 16:
170
- return {"name": gpu_name, "batch_size": 2, "grad_accum": 16, "vram_gb": vram_gb}
171
- else:
172
- return {"name": gpu_name, "batch_size": 1, "grad_accum": 32, "vram_gb": max(vram_gb, 8)}
173
-
174
- def get_ram_info():
175
- """Get RAM info in GB."""
176
- try:
177
- import psutil
178
- total = psutil.virtual_memory().total / 1024**3
179
- available = psutil.virtual_memory().available / 1024**3
180
- return total, available
181
- except ImportError:
182
- try:
183
- import subprocess
184
- result = subprocess.run(
185
- ['free', '-g', '--output=SIZE,AVAILABLE'],
186
- capture_output=True, text=True
187
  )
188
- lines = result.stdout.strip().split('\n')
189
- if len(lines) >= 2:
190
- total, available = map(float, lines[1].split())
191
- return total, available
192
- except:
193
- pass
194
- except:
195
- pass
196
- return 0, 0
197
-
198
- def limit_ram_usage(max_ram_gb):
199
- """Limit RAM usage by setting resource limits."""
200
- try:
201
- import resource
202
- max_bytes = int(max_ram_gb * 1024**3)
203
- resource.setrlimit(resource.RLIMIT_AS, (max_bytes, max_bytes))
204
- except:
205
- pass
206
-
207
- def log_memory_usage():
208
- """Log current memory usage."""
209
- msg = []
210
- if torch.cuda.is_available():
211
- used = torch.cuda.memory_allocated() / 1024**3
212
- reserved = torch.cuda.memory_reserved() / 1024**3
213
- msg.append(f"GPU: {used:.2f}GB / {reserved:.2f}GB")
214
- try:
215
- import psutil
216
- ram_used = psutil.virtual_memory().used / 1024**3
217
- ram_total = psutil.virtual_memory().total / 1024**3
218
- msg.append(f"RAM: {ram_used:.1f}GB / {ram_total:.1f}GB")
219
- except:
220
- pass
221
- return " | ".join(msg)
222
-
223
-
224
- # ============================================================
225
- # Speech Adapter (LLaMA-Omni 2 Style)
226
- # ============================================================
227
- class SpeechAdapter(nn.Module):
228
- """
229
- 5× downsampling + FFN with intermediate dim 2048
230
- """
231
- def __init__(self, whisper_dim=1280, llm_dim=3072, downsample=5, intermediate_dim=2048):
232
- super().__init__()
233
- self.downsample = downsample
234
- concat_dim = whisper_dim * downsample
235
-
236
- self.ffn = nn.Sequential(
237
- nn.Linear(concat_dim, intermediate_dim),
238
- nn.GELU(),
239
- nn.Linear(intermediate_dim, llm_dim),
240
- nn.LayerNorm(llm_dim)
241
  )
242
 
243
- def forward(self, x):
244
- B, T, D = x.shape
245
- T_new = (T // self.downsample) * self.downsample
246
- x = x[:, :T_new]
247
- x = x.reshape(B, T_new // self.downsample, D * self.downsample)
248
- return self.ffn(x)
249
 
 
 
250
 
251
- # ============================================================
252
- # Scheduled Interleaved Sequence Creation with Word Alignment
253
- # ============================================================
254
- def create_interleaved_sequence(text_tokens, snac_tokens, text_ratio=0.9, word_alignments=None, tokenizer=None, answer_text=None):
255
- """
256
- Create interleaved sequence based on text_ratio with word-level alignment.
257
- - text_ratio=0.9 means 90% of words are replaced by text tokens
258
- - text_ratio=0.0 means 100% audio (no text replacement)
259
 
260
- With word_alignments: replaces aligned audio spans with corresponding text tokens
261
- Without word_alignments: falls back to positional interleaving
262
- """
263
- interleaved = []
264
- is_audio_mask = []
265
-
266
- if len(snac_tokens) == 0:
267
- return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
268
-
269
- # Group SNAC into frames of 7
270
- frames = []
271
- for i in range(0, len(snac_tokens), 7):
272
- frame = snac_tokens[i:i+7]
273
- if len(frame) == 7:
274
- frames.append(frame)
275
-
276
- if len(frames) == 0:
277
- return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
278
-
279
- total_frames = len(frames)
280
-
281
- # If we have word alignments, use semantic interleaving
282
- # Check if alignments have pre-computed tokens (preferred) or need tokenizer
283
- has_precomputed = word_alignments and len(word_alignments) > 0 and 'tokens' in word_alignments[0] and word_alignments[0]['tokens']
284
- can_interleave = word_alignments and text_ratio > 0 and (has_precomputed or (tokenizer and answer_text))
285
-
286
- if can_interleave:
287
- import random
288
-
289
- # Decide which words to replace with text based on text_ratio
290
- num_words = len(word_alignments)
291
- num_text_words = int(num_words * text_ratio)
292
-
293
- # Randomly select which word indices to replace with text
294
- word_indices = list(range(num_words))
295
- random.shuffle(word_indices)
296
- text_word_indices = set(word_indices[:num_text_words])
297
-
298
- # Build interleaved sequence frame by frame
299
- frame_idx = 0
300
- snac_position = 0
301
-
302
- for word_idx, alignment in enumerate(word_alignments):
303
- word = alignment['word']
304
- start_frame = alignment['start_frame']
305
- end_frame = min(alignment['end_frame'], total_frames)
306
-
307
- if word_idx in text_word_indices:
308
- # Replace this word's audio with text tokens
309
- # Use pre-computed tokens if available, else tokenize on-the-fly
310
- word_tokens = alignment.get('tokens', [])
311
- if not word_tokens and tokenizer:
312
- word_tokens = tokenizer.encode(word, add_special_tokens=False)
313
-
314
- for tok in word_tokens:
315
- interleaved.append(tok)
316
- is_audio_mask.append(False)
317
- # Skip the audio frames for this word
318
- snac_position = end_frame * 7
319
- else:
320
- # Keep audio for this word
321
- for f_idx in range(start_frame, end_frame):
322
- if f_idx < total_frames:
323
- frame = frames[f_idx]
324
- for tok in frame:
325
- interleaved.append(apply_snac_offset(tok, snac_position))
326
- is_audio_mask.append(True)
327
- snac_position += 1
328
-
329
- frame_idx = end_frame
330
-
331
- # Add any remaining frames after the last word
332
- while frame_idx < total_frames:
333
- frame = frames[frame_idx]
334
- for tok in frame:
335
- interleaved.append(apply_snac_offset(tok, snac_position))
336
- is_audio_mask.append(True)
337
- snac_position += 1
338
- frame_idx += 1
339
-
340
- else:
341
- # Fallback: positional interleaving (original behavior)
342
- total_text = len(text_tokens)
343
-
344
- # Determine interleaving pattern based on text_ratio
345
- if text_ratio >= 0.9:
346
- text_per_chunk, frames_per_chunk = 1, 3
347
- elif text_ratio >= 0.7:
348
- text_per_chunk, frames_per_chunk = 1, 5
349
- elif text_ratio >= 0.5:
350
- text_per_chunk, frames_per_chunk = 1, 7
351
- elif text_ratio >= 0.3:
352
- text_per_chunk, frames_per_chunk = 1, 10
353
- else:
354
- text_per_chunk, frames_per_chunk = 0, 1
355
-
356
- text_idx = 0
357
- frame_idx = 0
358
- snac_position = 0
359
-
360
- while frame_idx < total_frames:
361
- if text_per_chunk > 0 and text_idx < total_text:
362
- for _ in range(text_per_chunk):
363
- if text_idx < total_text:
364
- interleaved.append(text_tokens[text_idx])
365
- is_audio_mask.append(False)
366
- text_idx += 1
367
-
368
- for _ in range(frames_per_chunk):
369
- if frame_idx < total_frames:
370
- frame = frames[frame_idx]
371
- for tok in frame:
372
- interleaved.append(apply_snac_offset(tok, snac_position))
373
- is_audio_mask.append(True)
374
- snac_position += 1
375
- frame_idx += 1
376
-
377
- while text_idx < total_text:
378
- interleaved.append(text_tokens[text_idx])
379
- is_audio_mask.append(False)
380
- text_idx += 1
381
-
382
- # Add EOS
383
- interleaved.append(EOS_TOKEN)
384
- is_audio_mask.append(False)
385
-
386
- return interleaved, is_audio_mask
387
-
388
-
389
- # ============================================================
390
- # Dataset
391
- # ============================================================
392
- class InterleavedDataset(Dataset):
393
- def __init__(self, data, tokenizer, max_audio_len=500, max_seq_len=2048):
394
- self.data = data
395
- self.tokenizer = tokenizer
396
- self.max_audio = max_audio_len * 5
397
- self.max_seq_len = max_seq_len
398
-
399
- def __len__(self):
400
- return len(self.data)
401
-
402
- def __getitem__(self, idx):
403
- item = self.data[idx]
404
-
405
- # Whisper features
406
- whisper = item["whisper_features"][:self.max_audio]
407
-
408
- # Text tokens - use pre-computed if available, otherwise tokenize
409
- if "text_tokens" in item and len(item["text_tokens"]) > 0:
410
- tt = item["text_tokens"]
411
- text_tokens = tt.tolist() if hasattr(tt, 'tolist') else list(tt)
412
- else:
413
- text = item.get("answer", item.get("text", ""))
414
- if isinstance(text, str) and len(text) > 0:
415
- text_tokens = self.tokenizer.encode(text, add_special_tokens=False)
416
- else:
417
- text_tokens = []
418
-
419
- # SNAC tokens
420
- snac = item["snac_tokens"]
421
- snac_len = (len(snac) // 7) * 7
422
- snac = snac[:snac_len] if snac_len > 0 else snac[:7]
423
- snac_list = snac.tolist() if hasattr(snac, 'tolist') else list(snac)
424
-
425
- # Word alignments (if available)
426
- word_alignments = item.get("word_alignments", None)
427
- answer_text = item.get("answer", "")
428
-
429
- return {
430
- "whisper": whisper,
431
- "text_tokens": text_tokens,
432
- "snac_tokens": snac_list,
433
- "word_alignments": word_alignments,
434
- "answer_text": answer_text
435
- }
436
-
437
-
438
- def collate_fn(batch, text_ratio=0.9, tokenizer=None):
439
- """Collate with dynamic interleaving based on text_ratio and word alignments."""
440
- max_w = max(b["whisper"].shape[0] for b in batch)
441
- max_w = ((max_w + 4) // 5) * 5
442
-
443
- whisper_batch = []
444
- interleaved_batch = []
445
- is_audio_batch = []
446
-
447
- max_seq = 0
448
- sequences = []
449
-
450
- for b in batch:
451
- interleaved, is_audio = create_interleaved_sequence(
452
- b["text_tokens"],
453
- b["snac_tokens"],
454
- text_ratio,
455
- word_alignments=b.get("word_alignments"),
456
- tokenizer=tokenizer,
457
- answer_text=b.get("answer_text")
458
  )
459
- sequences.append((interleaved, is_audio))
460
- max_seq = max(max_seq, len(interleaved))
461
 
462
- for i, b in enumerate(batch):
463
- w = b["whisper"]
464
- w_pad = F.pad(w, (0, 0, 0, max_w - w.shape[0]))
465
- whisper_batch.append(w_pad)
 
466
 
467
- interleaved, is_audio = sequences[i]
468
- seq_tensor = torch.tensor(interleaved, dtype=torch.long)
469
- mask_tensor = torch.tensor(is_audio, dtype=torch.bool)
470
 
471
- seq_pad = F.pad(seq_tensor, (0, max_seq - len(interleaved)), value=-100)
472
- mask_pad = F.pad(mask_tensor, (0, max_seq - len(is_audio)), value=False)
473
 
474
- interleaved_batch.append(seq_pad)
475
- is_audio_batch.append(mask_pad)
476
 
477
- return {
478
- "whisper": torch.stack(whisper_batch),
479
- "interleaved": torch.stack(interleaved_batch),
480
- "is_audio_mask": torch.stack(is_audio_batch)
481
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
 
484
- # ============================================================
485
- # Arguments
486
- # ============================================================
487
  def parse_args():
488
- parser = argparse.ArgumentParser(description="Stage 1: Adapter Only with Interleaved Output")
489
- parser.add_argument("--data", type=str, required=True)
 
 
 
 
 
 
490
  parser.add_argument("--output_dir", type=str, default="./checkpoints")
 
 
491
  parser.add_argument("--lr", type=float, default=5e-5)
492
- parser.add_argument("--epochs", type=int, default=2, help="1-2 epochs for adapter warmup")
 
493
  parser.add_argument("--batch_size", type=int, default=None)
494
  parser.add_argument("--grad_accum", type=int, default=None)
495
  parser.add_argument("--warmup_ratio", type=float, default=0.03)
496
  parser.add_argument("--max_grad_norm", type=float, default=1.0)
497
- parser.add_argument("--save_steps", type=int, default=200)
498
  parser.add_argument("--label_smoothing", type=float, default=0.1)
 
 
499
  # Scheduled interleaving
500
  parser.add_argument("--initial_text_ratio", type=float, default=0.9)
501
  parser.add_argument("--decay_steps", type=int, default=300)
 
 
 
 
 
 
 
502
  # Model
503
- parser.add_argument("--model_path", type=str, default="canopylabs/3b-es_it-ft-research_release")
 
 
 
 
504
  parser.add_argument("--resume", type=str, default=None)
505
- # Memory limits
506
- parser.add_argument("--vram_fraction", type=float, default=0.80, help="VRAM fraction to use (default 0.80)")
507
- parser.add_argument("--ram_limit_gb", type=float, default=None, help="RAM limit in GB (auto if not specified)")
 
 
 
508
  # Modes
509
  parser.add_argument("--demo", action="store_true")
510
  parser.add_argument("--test", action="store_true")
511
- # Gradient checkpointing
512
- parser.add_argument("--gradient_checkpointing", action="store_true", help="Enable gradient checkpointing (auto-enabled if VRAM < 20GB)")
513
  return parser.parse_args()
514
 
515
 
516
- # ============================================================
517
- # Main
518
- # ============================================================
519
  def main():
520
  args = parse_args()
521
-
522
- # Determine mixed precision based on device
523
- # MPS doesn't support bf16, CUDA/ROCm do
524
- mixed_precision = None
525
- if torch.cuda.is_available():
526
- mixed_precision = "bf16"
527
- # MPS and others use fp32 or fp16
528
-
529
- # Initialize Accelerator (auto-detects CUDA, ROCm, MPS, XPU)
530
- accelerator = Accelerator(
531
- gradient_accumulation_steps=args.grad_accum if args.grad_accum else 1,
532
- mixed_precision=mixed_precision,
533
- )
534
-
535
- device = accelerator.device
536
- is_main = accelerator.is_main_process
537
-
538
- # Set seed for reproducibility
539
- set_seed(42)
540
-
541
- # GPU config
542
- gpu_config = auto_detect_gpu_config()
543
- if args.batch_size is None:
544
- args.batch_size = gpu_config["batch_size"]
545
- if args.grad_accum is None:
546
- args.grad_accum = gpu_config["grad_accum"]
547
- # Update accelerator's gradient accumulation
548
- accelerator.gradient_accumulation_steps = args.grad_accum
549
-
550
- # Use bf16 for CUDA, fp32 for MPS/others
551
- torch_dtype = torch.bfloat16 if device.type == 'cuda' else torch.float32
552
-
553
- # Get RAM info and set limits
554
- ram_total, ram_available = get_ram_info()
555
- if args.ram_limit_gb is None:
556
- args.ram_limit_gb = ram_total * 0.80 # Default to 80% of RAM
557
- limit_ram_usage(args.ram_limit_gb)
558
-
559
- if is_main:
560
- log("=" * 60)
561
- log("STAGE 1: Adapter Only (LLM Frozen) + Interleaved Output")
562
- log("=" * 60)
563
- log(f"Device: {device} ({accelerator.device.type})")
564
- log(f"GPU: {gpu_config['name']} ({gpu_config['vram_gb']}GB)")
565
- log(f"Num processes: {accelerator.num_processes}")
566
- log(f"RAM: {ram_total:.1f}GB total, {ram_available:.1f}GB available")
567
- log(f"Batch: {args.batch_size}, Grad accum: {args.grad_accum}")
568
- log(f"LR: {args.lr}, Epochs: {args.epochs}")
569
- log(f"Initial text ratio: {args.initial_text_ratio}")
570
- log(f"Decay steps: {args.decay_steps}")
571
-
572
- # Apply memory limits for CUDA
573
- if device.type == 'cuda':
574
- torch.cuda.set_per_process_memory_fraction(args.vram_fraction)
575
- torch.cuda.empty_cache()
576
- torch.backends.cudnn.benchmark = True
577
- torch.set_float32_matmul_precision('high')
578
- if is_main:
579
- log(f"[MEMORY] VRAM limited to {args.vram_fraction*100:.0f}%")
580
- log(f"[MEMORY] RAM limited to {args.ram_limit_gb:.1f}GB")
581
-
582
- # HuggingFace login
583
- hf_token = os.environ.get("HF_TOKEN")
584
- if hf_token:
585
- login(token=hf_token)
586
-
587
- # Load tokenizer
588
- tokenizer = AutoTokenizer.from_pretrained(args.model_path)
589
- if tokenizer.pad_token is None:
590
- tokenizer.pad_token = tokenizer.eos_token
591
-
592
- # Load datasets
593
- data_paths = [p.strip() for p in args.data.split(",")]
594
- all_datasets = []
595
-
596
- if is_main:
597
- log("\nLoading datasets...")
598
-
599
- for path in data_paths:
600
- if os.path.exists(path):
601
- data = torch.load(path, weights_only=False, mmap=True)
602
- dataset = InterleavedDataset(data, tokenizer)
603
- all_datasets.append(dataset)
604
- if is_main:
605
- log(f" {os.path.basename(path)}: {len(data):,} samples")
606
-
607
- if len(all_datasets) == 0:
608
- raise ValueError("No datasets loaded!")
609
-
610
- combined_dataset = ConcatDataset(all_datasets) if len(all_datasets) > 1 else all_datasets[0]
611
-
612
- # Demo/Test mode
613
- if args.test:
614
- combined_dataset = torch.utils.data.Subset(combined_dataset, range(min(5, len(combined_dataset))))
615
- args.batch_size = min(args.batch_size, len(combined_dataset))
616
- args.grad_accum = 1
617
- elif args.demo:
618
- combined_dataset = torch.utils.data.Subset(combined_dataset, range(min(1000, len(combined_dataset))))
619
- args.batch_size = min(4, args.batch_size)
620
- args.grad_accum = max(8, args.grad_accum)
621
-
622
- if is_main:
623
- log(f"Total samples: {len(combined_dataset):,}")
624
-
625
- # Load LLM (FROZEN)
626
- if is_main:
627
- log(f"\nLoading LLM (FROZEN): {args.model_path}")
628
-
629
- llm = AutoModelForCausalLM.from_pretrained(
630
- args.model_path,
631
- torch_dtype=torch_dtype,
632
- attn_implementation="sdpa",
633
- )
634
-
635
- # Freeze LLM completely
636
- for p in llm.parameters():
637
- p.requires_grad = False
638
- llm.eval()
639
-
640
- # Gradient checkpointing auto-detection
641
- use_gradient_checkpointing = args.gradient_checkpointing
642
- if not use_gradient_checkpointing and torch.cuda.is_available():
643
- vram_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3
644
- if vram_gb < 20:
645
- use_gradient_checkpointing = True
646
- if is_main:
647
- log(f"[AUTO] Enabling gradient checkpointing (VRAM={vram_gb:.1f}GB < 20GB)")
648
- else:
649
- if is_main:
650
- log(f"[AUTO] Gradient checkpointing disabled for speed (VRAM={vram_gb:.1f}GB >= 20GB)")
651
-
652
- if use_gradient_checkpointing:
653
- llm.gradient_checkpointing_enable()
654
- if is_main:
655
- log("[MEMORY] Gradient checkpointing enabled")
656
-
657
- # Create adapter (TRAINABLE)
658
- adapter = SpeechAdapter(
659
- whisper_dim=1280,
660
- llm_dim=3072,
661
- downsample=5,
662
- intermediate_dim=2048
663
- ).to(dtype=torch_dtype)
664
-
665
- adapter_params = sum(p.numel() for p in adapter.parameters())
666
- if is_main:
667
- log(f"\nTrainable: Adapter only ({adapter_params:,} = {adapter_params/1e6:.1f}M params)")
668
- log("LLM: FROZEN")
669
-
670
- # Optimizer (only adapter)
671
- optimizer = torch.optim.AdamW(adapter.parameters(), lr=args.lr, weight_decay=0.01)
672
-
673
- # Training state
674
- global_step = 0
675
- start_epoch = 0
676
- best_loss = float("inf")
677
- current_text_ratio = args.initial_text_ratio
678
-
679
- # Resume
680
- if args.resume and os.path.exists(args.resume):
681
- if is_main:
682
- log(f"\nResuming from: {args.resume}")
683
- ckpt = torch.load(args.resume, map_location="cpu", weights_only=False)
684
- if "adapter" in ckpt:
685
- adapter.load_state_dict(ckpt["adapter"])
686
- if "optimizer" in ckpt:
687
- optimizer.load_state_dict(ckpt["optimizer"])
688
- if "step" in ckpt:
689
- global_step = ckpt["step"]
690
- if "epoch" in ckpt:
691
- start_epoch = ckpt["epoch"]
692
- if "text_ratio" in ckpt:
693
- current_text_ratio = ckpt["text_ratio"]
694
-
695
- # Training
696
- os.makedirs(args.output_dir, exist_ok=True)
697
-
698
- # Create dataloader with dynamic collate
699
- def collate_with_ratio(batch):
700
- current_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
701
- return collate_fn(batch, current_ratio, tokenizer=tokenizer)
702
-
703
- train_loader = DataLoader(
704
- combined_dataset,
705
- batch_size=args.batch_size,
706
- shuffle=True,
707
- collate_fn=collate_with_ratio,
708
- num_workers=4,
709
- pin_memory=True
710
- )
711
-
712
- # Prepare with accelerator (handles DDP, device placement, etc.)
713
- adapter, llm, optimizer, train_loader = accelerator.prepare(
714
- adapter, llm, optimizer, train_loader
715
- )
716
-
717
- steps_per_epoch = max(1, len(train_loader) // args.grad_accum)
718
- total_steps = max(1, steps_per_epoch * args.epochs)
719
- warmup_steps = int(total_steps * args.warmup_ratio)
720
-
721
- scheduler = CosineAnnealingLR(optimizer, T_max=max(1, total_steps - warmup_steps), eta_min=1e-6)
722
-
723
- if is_main:
724
- log(f"Steps per epoch: {steps_per_epoch}, Total: {total_steps}, Warmup: {warmup_steps}")
725
-
726
- if is_main:
727
- log("\n" + "=" * 60)
728
- log("STARTING STAGE 1 TRAINING")
729
- log("=" * 60)
730
-
731
- for epoch in range(start_epoch, args.epochs):
732
- # Update text ratio based on global step
733
- current_text_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
734
-
735
- # Get unwrapped adapter for forward pass
736
- unwrapped_adapter = accelerator.unwrap_model(adapter)
737
- unwrapped_llm = accelerator.unwrap_model(llm)
738
-
739
- adapter.train()
740
- epoch_loss = 0
741
- accum_loss = 0
742
-
743
- pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{args.epochs}", disable=not is_main)
744
-
745
- for batch_idx, batch in enumerate(pbar):
746
- # Update text ratio dynamically
747
- current_text_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
748
-
749
- whisper = batch["whisper"]
750
- interleaved = batch["interleaved"]
751
-
752
- # Use gradient accumulation context
753
- with accelerator.accumulate(adapter):
754
- # Forward through adapter
755
- audio_embeds = adapter(whisper)
756
-
757
- # Get token embeddings for interleaved sequence (teacher forcing)
758
- input_tokens = interleaved[:, :-1].clamp(min=0)
759
- with torch.no_grad():
760
- token_embeds = unwrapped_llm.model.embed_tokens(input_tokens)
761
-
762
- # Combine: [audio_embeds] + [token_embeds]
763
- combined = torch.cat([audio_embeds, token_embeds], dim=1)
764
-
765
- # Forward through frozen LLM
766
- outputs = llm(inputs_embeds=combined, use_cache=False)
767
- logits = outputs.logits
768
-
769
- # Loss: predict interleaved tokens after audio prefix
770
- audio_len = audio_embeds.shape[1]
771
- seq_len = interleaved.shape[1]
772
-
773
- seq_logits = logits[:, audio_len-1:audio_len-1+seq_len]
774
- targets = interleaved
775
-
776
- loss = F.cross_entropy(
777
- seq_logits.reshape(-1, logits.size(-1)),
778
- targets.reshape(-1),
779
- ignore_index=-100,
780
- label_smoothing=args.label_smoothing
781
- )
782
-
783
- accelerator.backward(loss)
784
- accum_loss += loss.item()
785
-
786
- # Clip gradients and step
787
- if accelerator.sync_gradients:
788
- accelerator.clip_grad_norm_(adapter.parameters(), args.max_grad_norm)
789
-
790
- optimizer.step()
791
- optimizer.zero_grad()
792
-
793
- # Update after accumulation
794
- if accelerator.sync_gradients:
795
- if global_step < warmup_steps:
796
- lr_scale = (global_step + 1) / warmup_steps
797
- for pg in optimizer.param_groups:
798
- pg["lr"] = args.lr * lr_scale
799
- else:
800
- scheduler.step()
801
-
802
- global_step += 1
803
- epoch_loss += accum_loss
804
-
805
- pbar.set_postfix(
806
- loss=f"{accum_loss:.4f}",
807
- text_ratio=f"{current_text_ratio:.1f}",
808
- lr=f"{optimizer.param_groups[0]['lr']:.2e}"
809
- )
810
-
811
- # Save checkpoint
812
- if global_step % args.save_steps == 0 and is_main:
813
- accelerator.wait_for_everyone()
814
- unwrapped_adapter = accelerator.unwrap_model(adapter)
815
- ckpt_path = os.path.join(args.output_dir, f"stage1_step{global_step}.pt")
816
- save_checkpoint_async({
817
- "adapter": unwrapped_adapter.state_dict(),
818
- "optimizer": optimizer.state_dict(),
819
- "step": global_step,
820
- "epoch": epoch,
821
- "loss": accum_loss,
822
- "text_ratio": current_text_ratio
823
- }, ckpt_path, is_main)
824
-
825
- accum_loss = 0
826
-
827
- # Epoch end - divide by grad_accum to get per-batch average
828
- avg_loss = epoch_loss / max(1, steps_per_epoch) / args.grad_accum
829
-
830
- if is_main:
831
- log(f"Epoch {epoch+1} avg loss: {avg_loss:.4f}, text_ratio: {current_text_ratio:.1f}")
832
-
833
- accelerator.wait_for_everyone()
834
- unwrapped_adapter = accelerator.unwrap_model(adapter)
835
- ckpt_path = os.path.join(args.output_dir, f"stage1_epoch{epoch+1}.pt")
836
- save_checkpoint_async({
837
- "adapter": unwrapped_adapter.state_dict(),
838
- "optimizer": optimizer.state_dict(),
839
- "step": global_step,
840
- "epoch": epoch + 1,
841
- "loss": avg_loss,
842
- "text_ratio": current_text_ratio
843
- }, ckpt_path, is_main)
844
-
845
- if avg_loss < best_loss:
846
- best_loss = avg_loss
847
- best_path = os.path.join(args.output_dir, "stage1_best.pt")
848
- save_checkpoint_async({
849
- "adapter": unwrapped_adapter.state_dict(),
850
- "step": global_step,
851
- "epoch": epoch + 1,
852
- "loss": best_loss,
853
- "text_ratio": current_text_ratio
854
- }, best_path, is_main)
855
- log(f"Best model saved! Loss: {best_loss:.4f}")
856
-
857
- # Finish
858
- accelerator.wait_for_everyone()
859
- if is_main:
860
- wait_for_checkpoints()
861
- log("\n" + "=" * 60)
862
- log("STAGE 1 COMPLETE!")
863
- log(f"Best loss: {best_loss:.4f}")
864
- log(f"Final text_ratio: {current_text_ratio:.1f}")
865
- log("Next: Stage 2 (Adapter + LoRA)")
866
- log("=" * 60)
867
 
868
 
869
  if __name__ == "__main__":
 
8
  - LLM is completely frozen (adapter gets a "head start")
9
 
10
  Usage:
11
+ python passo2_finetune_stage1.py --data data.pt --epochs 2
12
 
13
  Next: Stage 2 trains Adapter + LoRA together
14
  """
15
 
 
 
16
  import argparse
17
  import torch
18
+ from typing import Dict, Any
19
+
20
+ from training.config import TrainingConfig, GPUConfig
21
+ from training.models import SpeechAdapter, ModelFactory
22
+ from training.checkpoint import Stage1CheckpointManager, TrainingState
23
+ from training.trainer import BaseTrainer
24
+ from training.utils import log, should_enable_gradient_checkpointing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
 
27
+ class Stage1Trainer(BaseTrainer):
28
  """
29
+ Stage 1 Trainer: Adapter only (LLM frozen).
30
+
31
+ Trains only the SpeechAdapter while keeping the LLM completely frozen.
32
+ This gives the adapter a "head start" in learning the audio→embedding mapping.
33
  """
34
+
35
+ def _get_stage_name(self) -> str:
36
+ return "STAGE 1: Adapter Only (LLM Frozen)"
37
+
38
+ def _setup_models(self):
39
+ """Setup adapter and frozen LLM."""
40
+ gpu_config = GPUConfig.auto_detect()
41
+ dtype = gpu_config.dtype
42
+
43
+ if self.is_main:
44
+ log(f"\nLoading LLM (FROZEN): {self.config.model_path}")
45
+
46
+ # Check if gradient checkpointing needed
47
+ use_checkpointing = self.config.gradient_checkpointing or \
48
+ should_enable_gradient_checkpointing(
49
+ gpu_config.vram_gb,
50
+ self.config.dynamic_decay
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  )
52
+
53
+ # Load frozen LLM
54
+ self.llm = ModelFactory.create_llm(
55
+ self.config.model_path,
56
+ dtype=dtype,
57
+ freeze=True,
58
+ gradient_checkpointing=use_checkpointing,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  )
60
 
61
+ if self.is_main and use_checkpointing:
62
+ log("[Memory] Gradient checkpointing enabled")
 
 
 
 
63
 
64
+ # Create adapter
65
+ self.adapter = SpeechAdapter().to(dtype=dtype)
66
 
67
+ if self.is_main:
68
+ params = self.adapter.get_num_params()
69
+ log(f"\nTrainable: Adapter only ({params:,} = {params/1e6:.1f}M params)")
70
+ log("LLM: FROZEN")
 
 
 
 
71
 
72
+ def _setup_optimizer(self):
73
+ """Setup optimizer for adapter only."""
74
+ self.optimizer = torch.optim.AdamW(
75
+ self.adapter.parameters(),
76
+ lr=self.config.learning_rate,
77
+ weight_decay=self.config.weight_decay,
78
+ )
79
+
80
+ def _setup_checkpoint_manager(self):
81
+ """Setup Stage 1 checkpoint manager."""
82
+ self.checkpoint_manager = Stage1CheckpointManager(
83
+ self.config.output_dir,
84
+ verbose=self.is_main,
85
+ )
86
+
87
+ def _load_checkpoint(self, ckpt: Dict[str, Any]):
88
+ """Load checkpoint state."""
89
+ if "adapter" in ckpt:
90
+ self.adapter.load_state_dict(ckpt["adapter"])
91
+ if "optimizer" in ckpt:
92
+ self.optimizer.load_state_dict(ckpt["optimizer"])
93
+ if "step" in ckpt:
94
+ self.global_step = ckpt["step"]
95
+ if "epoch" in ckpt:
96
+ self.start_epoch = ckpt["epoch"]
97
+ if "text_ratio" in ckpt:
98
+ self.current_text_ratio = ckpt["text_ratio"]
99
+
100
+ def _get_trainable_params(self):
101
+ """Get trainable parameters (adapter only)."""
102
+ return self.adapter.parameters()
103
+
104
+ def _save_step_checkpoint(self, loss: float):
105
+ """Save step checkpoint."""
106
+ self.accelerator.wait_for_everyone()
107
+ unwrapped = self.accelerator.unwrap_model(self.adapter)
108
+
109
+ state = TrainingState(
110
+ step=self.global_step,
111
+ epoch=self.start_epoch,
112
+ loss=loss,
113
+ text_ratio=self.current_text_ratio,
114
+ best_loss=self.best_loss,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  )
 
 
116
 
117
+ self.checkpoint_manager.save_step(
118
+ unwrapped.state_dict(),
119
+ self.optimizer.state_dict(),
120
+ state,
121
+ )
122
 
123
+ def _finish_epoch(self, epoch: int, epoch_loss: float):
124
+ """Finish epoch and save checkpoints."""
125
+ avg_loss = epoch_loss / max(1, self.steps_per_epoch) / self.config.grad_accum
126
 
127
+ if self.is_main:
128
+ log(f"Epoch {epoch+1} avg loss: {avg_loss:.4f}, text_ratio: {self.current_text_ratio:.1f}")
129
 
130
+ self.accelerator.wait_for_everyone()
131
+ unwrapped = self.accelerator.unwrap_model(self.adapter)
132
 
133
+ state = TrainingState(
134
+ step=self.global_step,
135
+ epoch=epoch + 1,
136
+ loss=avg_loss,
137
+ text_ratio=self.current_text_ratio,
138
+ best_loss=self.best_loss,
139
+ )
140
+
141
+ # Save epoch checkpoint
142
+ self.checkpoint_manager.save_epoch(
143
+ unwrapped.state_dict(),
144
+ self.optimizer.state_dict(),
145
+ state,
146
+ )
147
+
148
+ # Save best if improved
149
+ if avg_loss < self.best_loss:
150
+ self.best_loss = avg_loss
151
+ state.best_loss = self.best_loss
152
+ self.checkpoint_manager.save_best(unwrapped.state_dict(), state)
153
+ log(f"Best model saved! Loss: {self.best_loss:.4f}")
154
 
155
 
 
 
 
156
  def parse_args():
157
+ """Parse command line arguments."""
158
+ parser = argparse.ArgumentParser(
159
+ description="Stage 1: Adapter Only with Interleaved Output"
160
+ )
161
+
162
+ # Data
163
+ parser.add_argument("--data", type=str, required=True,
164
+ help="Dataset path(s), comma-separated")
165
  parser.add_argument("--output_dir", type=str, default="./checkpoints")
166
+
167
+ # Training
168
  parser.add_argument("--lr", type=float, default=5e-5)
169
+ parser.add_argument("--epochs", type=int, default=2,
170
+ help="1-2 epochs for adapter warmup")
171
  parser.add_argument("--batch_size", type=int, default=None)
172
  parser.add_argument("--grad_accum", type=int, default=None)
173
  parser.add_argument("--warmup_ratio", type=float, default=0.03)
174
  parser.add_argument("--max_grad_norm", type=float, default=1.0)
 
175
  parser.add_argument("--label_smoothing", type=float, default=0.1)
176
+ parser.add_argument("--max_seq_len", type=int, default=2048)
177
+
178
  # Scheduled interleaving
179
  parser.add_argument("--initial_text_ratio", type=float, default=0.9)
180
  parser.add_argument("--decay_steps", type=int, default=300)
181
+ parser.add_argument("--dynamic_decay", action="store_true")
182
+ parser.add_argument("--no_decay", action="store_true", default=True,
183
+ help="Keep text_ratio fixed (recommended for Stage 1)")
184
+ parser.add_argument("--use_decay", action="store_true",
185
+ help="Enable decay (overrides --no_decay)")
186
+ parser.add_argument("--final_audio_portion", type=float, default=0.2)
187
+
188
  # Model
189
+ parser.add_argument("--model_path", type=str,
190
+ default="canopylabs/3b-es_it-ft-research_release")
191
+
192
+ # Checkpointing
193
+ parser.add_argument("--save_steps", type=int, default=200)
194
  parser.add_argument("--resume", type=str, default=None)
195
+
196
+ # Memory
197
+ parser.add_argument("--vram_fraction", type=float, default=0.80)
198
+ parser.add_argument("--ram_limit_gb", type=float, default=None)
199
+ parser.add_argument("--gradient_checkpointing", action="store_true")
200
+
201
  # Modes
202
  parser.add_argument("--demo", action="store_true")
203
  parser.add_argument("--test", action="store_true")
204
+
 
205
  return parser.parse_args()
206
 
207
 
 
 
 
208
  def main():
209
  args = parse_args()
210
+ # --use_decay overrides --no_decay
211
+ if args.use_decay:
212
+ args.no_decay = False
213
+ config = TrainingConfig.from_args(args)
214
+
215
+ trainer = Stage1Trainer(config)
216
+ trainer.setup()
217
+ trainer.train()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
 
220
  if __name__ == "__main__":
passo3_finetune_stage2.py CHANGED
@@ -9,916 +9,289 @@ Continues from Stage 1 checkpoint:
9
  - Continues scheduled interleaving (90% text -> 0% text)
10
 
11
  Usage:
12
- python finetune_stage2.py --data data.pt --stage1_ckpt checkpoints/stage1_best.pt --epochs 3
13
 
14
  Based on IST-LM paper + LLaMA-Omni 2 staging approach.
15
  """
16
 
17
- import os
18
- import sys
19
  import argparse
20
  import torch
21
- import torch.nn as nn
22
- import torch.nn.functional as F
23
- from torch.utils.data import Dataset, DataLoader, ConcatDataset
24
- from torch.optim.lr_scheduler import CosineAnnealingLR
25
- from transformers import AutoModelForCausalLM, AutoTokenizer
26
- from peft import LoraConfig, get_peft_model, TaskType
27
- from accelerate import Accelerator
28
- from accelerate.utils import set_seed
29
- from huggingface_hub import login
30
- from tqdm import tqdm
31
- import threading
32
-
33
- # ============================================================
34
- # Config
35
- # ============================================================
36
- torch.backends.cuda.matmul.allow_tf32 = True
37
- torch.backends.cudnn.allow_tf32 = True
38
- torch.backends.cudnn.benchmark = True
39
- torch.set_float32_matmul_precision('high')
40
-
41
- # SNAC token offsets for Orpheus
42
- SNAC_BASE_OFFSET = 128266
43
- EOS_TOKEN = 128009
44
-
45
-
46
- def log(msg):
47
- print(msg)
48
- sys.stdout.flush()
49
-
50
-
51
- def apply_snac_offset(token_idx, position):
52
- """Apply position-based offset to SNAC token.
53
- If token is already offset (>= SNAC_BASE_OFFSET), return as-is.
54
- """
55
- if int(token_idx) >= SNAC_BASE_OFFSET:
56
- # Already has offset applied
57
- return int(token_idx)
58
- offset = SNAC_BASE_OFFSET + (position % 7) * 4096
59
- return int(token_idx) + offset
60
 
61
-
62
- def get_text_ratio(global_step, decay_steps=300, initial_ratio=0.9, min_ratio=0.0):
63
- """
64
- IST-LM: Start with 90% text, decrease by 0.1 every 300 steps.
65
- """
66
- num_decays = global_step // decay_steps
67
- text_ratio = initial_ratio - (num_decays * 0.1)
68
- return max(min_ratio, text_ratio)
69
-
70
-
71
- # ============================================================
72
- # Async Checkpoint Saving
73
- # ============================================================
74
- _save_threads = []
75
-
76
- def save_checkpoint_async(state_dict, path, is_main=True):
77
- global _save_threads
78
- _save_threads = [t for t in _save_threads if t.is_alive()]
79
-
80
- def copy_to_cpu(obj):
81
- if isinstance(obj, torch.Tensor):
82
- return obj.detach().cpu().clone()
83
- elif isinstance(obj, dict):
84
- return {k: copy_to_cpu(v) for k, v in obj.items()}
85
- return obj
86
-
87
- state_copy = copy_to_cpu(state_dict)
88
-
89
- def _save():
90
- try:
91
- torch.save(state_copy, path)
92
- if is_main:
93
- log(f"[ASYNC] Saved: {path}")
94
- except Exception as e:
95
- if is_main:
96
- log(f"[ASYNC] Error: {e}")
97
-
98
- thread = threading.Thread(target=_save, daemon=True)
99
- thread.start()
100
- _save_threads.append(thread)
101
-
102
-
103
- def wait_for_checkpoints():
104
- global _save_threads
105
- for t in _save_threads:
106
- t.join()
107
- _save_threads = []
108
-
109
-
110
- # ============================================================
111
- # GPU Auto-Detection (Multi-backend: CUDA, ROCm, MPS, XPU)
112
- # ============================================================
113
- def auto_detect_gpu_config():
114
- """Detect GPU and return optimal batch size config."""
115
- vram_gb = 0
116
- gpu_name = "Unknown"
117
-
118
- # Try CUDA (NVIDIA)
119
- if torch.cuda.is_available():
120
- try:
121
- props = torch.cuda.get_device_properties(0)
122
- vram_gb = props.total_memory // (1024**3)
123
- gpu_name = props.name
124
- except:
125
- pass
126
-
127
- # Try MPS (Apple Silicon)
128
- elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
129
- gpu_name = "Apple Silicon (MPS)"
130
- try:
131
- import subprocess
132
- result = subprocess.run(['sysctl', '-n', 'hw.memsize'], capture_output=True, text=True)
133
- total_mem = int(result.stdout.strip()) // (1024**3)
134
- vram_gb = total_mem // 2
135
- except:
136
- vram_gb = 8
137
-
138
- # Try ROCm (AMD)
139
- elif hasattr(torch, 'hip') or os.environ.get('ROCM_HOME'):
140
- try:
141
- if torch.cuda.is_available():
142
- props = torch.cuda.get_device_properties(0)
143
- vram_gb = props.total_memory // (1024**3)
144
- gpu_name = f"AMD {props.name}"
145
- except:
146
- gpu_name = "AMD ROCm"
147
- vram_gb = 16
148
-
149
- # Fallback: try nvidia-smi
150
- if vram_gb == 0:
151
- try:
152
- import subprocess
153
- result = subprocess.run(
154
- ['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],
155
- capture_output=True, text=True
156
- )
157
- lines = result.stdout.strip().split('\n')
158
- gpu_name, vram_mb = lines[0].split(', ')
159
- vram_gb = int(vram_mb) // 1024
160
- except:
161
- pass
162
-
163
- # Determine batch size based on VRAM
164
- if vram_gb >= 150:
165
- return {"name": gpu_name, "batch_size": 8, "grad_accum": 4, "vram_gb": vram_gb}
166
- elif vram_gb >= 80:
167
- return {"name": gpu_name, "batch_size": 6, "grad_accum": 5, "vram_gb": vram_gb}
168
- elif vram_gb >= 35:
169
- return {"name": gpu_name, "batch_size": 4, "grad_accum": 8, "vram_gb": vram_gb}
170
- elif vram_gb >= 16:
171
- return {"name": gpu_name, "batch_size": 2, "grad_accum": 16, "vram_gb": vram_gb}
172
- else:
173
- return {"name": gpu_name, "batch_size": 1, "grad_accum": 32, "vram_gb": max(vram_gb, 8)}
174
-
175
- def get_ram_info():
176
- """Get RAM info in GB."""
177
- try:
178
- import psutil
179
- total = psutil.virtual_memory().total / 1024**3
180
- available = psutil.virtual_memory().available / 1024**3
181
- return total, available
182
- except ImportError:
183
- try:
184
- import subprocess
185
- result = subprocess.run(
186
- ['free', '-g', '--output=SIZE,AVAILABLE'],
187
- capture_output=True, text=True
188
- )
189
- lines = result.stdout.strip().split('\n')
190
- if len(lines) >= 2:
191
- total, available = map(float, lines[1].split())
192
- return total, available
193
- except:
194
- pass
195
- except:
196
- pass
197
- return 0, 0
198
-
199
- def limit_ram_usage(max_ram_gb):
200
- """Limit RAM usage by setting resource limits."""
201
- try:
202
- import resource
203
- max_bytes = int(max_ram_gb * 1024**3)
204
- resource.setrlimit(resource.RLIMIT_AS, (max_bytes, max_bytes))
205
- except:
206
- pass
207
-
208
- def log_memory_usage():
209
- """Log current memory usage."""
210
- msg = []
211
- if torch.cuda.is_available():
212
- used = torch.cuda.memory_allocated() / 1024**3
213
- reserved = torch.cuda.memory_reserved() / 1024**3
214
- msg.append(f"GPU: {used:.2f}GB / {reserved:.2f}GB")
215
- try:
216
- import psutil
217
- ram_used = psutil.virtual_memory().used / 1024**3
218
- ram_total = psutil.virtual_memory().total / 1024**3
219
- msg.append(f"RAM: {ram_used:.1f}GB / {ram_total:.1f}GB")
220
- except:
221
- pass
222
- return " | ".join(msg)
223
-
224
-
225
- # ============================================================
226
- # Speech Adapter (Same as Stage 1 - LLaMA-Omni 2 Style)
227
- # ============================================================
228
- class SpeechAdapter(nn.Module):
229
- """
230
- 5x downsampling + FFN with intermediate dim 2048
231
- MUST match Stage 1 architecture exactly for checkpoint loading.
232
- """
233
- def __init__(self, whisper_dim=1280, llm_dim=3072, downsample=5, intermediate_dim=2048):
234
- super().__init__()
235
- self.downsample = downsample
236
- concat_dim = whisper_dim * downsample
237
-
238
- self.ffn = nn.Sequential(
239
- nn.Linear(concat_dim, intermediate_dim),
240
- nn.GELU(),
241
- nn.Linear(intermediate_dim, llm_dim),
242
- nn.LayerNorm(llm_dim)
243
- )
244
-
245
- def forward(self, x):
246
- B, T, D = x.shape
247
- T_new = (T // self.downsample) * self.downsample
248
- x = x[:, :T_new]
249
- x = x.reshape(B, T_new // self.downsample, D * self.downsample)
250
- return self.ffn(x)
251
 
252
 
253
- # ============================================================
254
- # Scheduled Interleaved Sequence Creation with Word Alignment
255
- # ============================================================
256
- def create_interleaved_sequence(text_tokens, snac_tokens, text_ratio=0.9, word_alignments=None, tokenizer=None, answer_text=None):
257
  """
258
- Create interleaved sequence based on text_ratio with word-level alignment.
259
- - text_ratio=0.9 means 90% of words are replaced by text tokens
260
- - text_ratio=0.0 means 100% audio (no text replacement)
261
 
262
- With word_alignments: replaces aligned audio spans with corresponding text tokens
263
- Without word_alignments: falls back to positional interleaving
264
  """
265
- interleaved = []
266
- is_audio_mask = []
267
-
268
- if len(snac_tokens) == 0:
269
- return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
270
-
271
- # Group SNAC into frames of 7
272
- frames = []
273
- for i in range(0, len(snac_tokens), 7):
274
- frame = snac_tokens[i:i+7]
275
- if len(frame) == 7:
276
- frames.append(frame)
277
-
278
- if len(frames) == 0:
279
- return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
280
-
281
- total_frames = len(frames)
282
-
283
- # If we have word alignments, use semantic interleaving
284
- # Check if alignments have pre-computed tokens (preferred) or need tokenizer
285
- has_precomputed = word_alignments and len(word_alignments) > 0 and 'tokens' in word_alignments[0] and word_alignments[0]['tokens']
286
- can_interleave = word_alignments and text_ratio > 0 and (has_precomputed or (tokenizer and answer_text))
287
-
288
- if can_interleave:
289
- import random
290
-
291
- # Decide which words to replace with text based on text_ratio
292
- num_words = len(word_alignments)
293
- num_text_words = int(num_words * text_ratio)
294
-
295
- # Randomly select which word indices to replace with text
296
- word_indices = list(range(num_words))
297
- random.shuffle(word_indices)
298
- text_word_indices = set(word_indices[:num_text_words])
299
-
300
- # Build interleaved sequence frame by frame
301
- frame_idx = 0
302
- snac_position = 0
303
-
304
- for word_idx, alignment in enumerate(word_alignments):
305
- word = alignment['word']
306
- start_frame = alignment['start_frame']
307
- end_frame = min(alignment['end_frame'], total_frames)
308
-
309
- if word_idx in text_word_indices:
310
- # Replace this word's audio with text tokens
311
- # Use pre-computed tokens if available, else tokenize on-the-fly
312
- word_tokens = alignment.get('tokens', [])
313
- if not word_tokens and tokenizer:
314
- word_tokens = tokenizer.encode(word, add_special_tokens=False)
315
-
316
- for tok in word_tokens:
317
- interleaved.append(tok)
318
- is_audio_mask.append(False)
319
- # Skip the audio frames for this word
320
- snac_position = end_frame * 7
321
- else:
322
- # Keep audio for this word
323
- for f_idx in range(start_frame, end_frame):
324
- if f_idx < total_frames:
325
- frame = frames[f_idx]
326
- for tok in frame:
327
- interleaved.append(apply_snac_offset(tok, snac_position))
328
- is_audio_mask.append(True)
329
- snac_position += 1
330
-
331
- frame_idx = end_frame
332
-
333
- # Add any remaining frames after the last word
334
- while frame_idx < total_frames:
335
- frame = frames[frame_idx]
336
- for tok in frame:
337
- interleaved.append(apply_snac_offset(tok, snac_position))
338
- is_audio_mask.append(True)
339
- snac_position += 1
340
- frame_idx += 1
341
-
342
- else:
343
- # Fallback: positional interleaving (original behavior)
344
- total_text = len(text_tokens)
345
-
346
- # Determine interleaving pattern based on text_ratio
347
- if text_ratio >= 0.9:
348
- text_per_chunk, frames_per_chunk = 1, 3
349
- elif text_ratio >= 0.7:
350
- text_per_chunk, frames_per_chunk = 1, 5
351
- elif text_ratio >= 0.5:
352
- text_per_chunk, frames_per_chunk = 1, 7
353
- elif text_ratio >= 0.3:
354
- text_per_chunk, frames_per_chunk = 1, 10
355
- else:
356
- text_per_chunk, frames_per_chunk = 0, 1
357
-
358
- text_idx = 0
359
- frame_idx = 0
360
- snac_position = 0
361
-
362
- while frame_idx < total_frames:
363
- if text_per_chunk > 0 and text_idx < total_text:
364
- for _ in range(text_per_chunk):
365
- if text_idx < total_text:
366
- interleaved.append(text_tokens[text_idx])
367
- is_audio_mask.append(False)
368
- text_idx += 1
369
-
370
- for _ in range(frames_per_chunk):
371
- if frame_idx < total_frames:
372
- frame = frames[frame_idx]
373
- for tok in frame:
374
- interleaved.append(apply_snac_offset(tok, snac_position))
375
- is_audio_mask.append(True)
376
- snac_position += 1
377
- frame_idx += 1
378
-
379
- while text_idx < total_text:
380
- interleaved.append(text_tokens[text_idx])
381
- is_audio_mask.append(False)
382
- text_idx += 1
383
-
384
- # Add EOS
385
- interleaved.append(EOS_TOKEN)
386
- is_audio_mask.append(False)
387
-
388
- return interleaved, is_audio_mask
389
-
390
-
391
- # ============================================================
392
- # Dataset
393
- # ============================================================
394
- class InterleavedDataset(Dataset):
395
- def __init__(self, data, tokenizer, max_audio_len=500, max_seq_len=2048):
396
- self.data = data
397
- self.tokenizer = tokenizer
398
- self.max_audio = max_audio_len * 5
399
- self.max_seq_len = max_seq_len
400
-
401
- def __len__(self):
402
- return len(self.data)
403
-
404
- def __getitem__(self, idx):
405
- item = self.data[idx]
406
-
407
- # Whisper features
408
- whisper = item["whisper_features"][:self.max_audio]
409
-
410
- # Text tokens - use pre-computed if available, otherwise tokenize
411
- if "text_tokens" in item and len(item["text_tokens"]) > 0:
412
- tt = item["text_tokens"]
413
- text_tokens = tt.tolist() if hasattr(tt, 'tolist') else list(tt)
414
- else:
415
- text = item.get("answer", item.get("text", ""))
416
- if isinstance(text, str) and len(text) > 0:
417
- text_tokens = self.tokenizer.encode(text, add_special_tokens=False)
418
- else:
419
- text_tokens = []
420
-
421
- # SNAC tokens
422
- snac = item["snac_tokens"]
423
- snac_len = (len(snac) // 7) * 7
424
- snac = snac[:snac_len] if snac_len > 0 else snac[:7]
425
- snac_list = snac.tolist() if hasattr(snac, 'tolist') else list(snac)
426
-
427
- # Word alignments (if available)
428
- word_alignments = item.get("word_alignments", None)
429
- answer_text = item.get("answer", "")
430
 
431
- return {
432
- "whisper": whisper,
433
- "text_tokens": text_tokens,
434
- "snac_tokens": snac_list,
435
- "word_alignments": word_alignments,
436
- "answer_text": answer_text
437
- }
438
 
 
 
439
 
440
- def collate_fn(batch, text_ratio=0.9, tokenizer=None):
441
- """Collate with dynamic interleaving based on text_ratio and word alignments."""
442
- max_w = max(b["whisper"].shape[0] for b in batch)
443
- max_w = ((max_w + 4) // 5) * 5
444
 
445
- whisper_batch = []
446
- interleaved_batch = []
447
- is_audio_batch = []
 
 
 
448
 
449
- max_seq = 0
450
- sequences = []
451
 
452
- for b in batch:
453
- interleaved, is_audio = create_interleaved_sequence(
454
- b["text_tokens"],
455
- b["snac_tokens"],
456
- text_ratio,
457
- word_alignments=b.get("word_alignments"),
458
- tokenizer=tokenizer,
459
- answer_text=b.get("answer_text")
460
  )
461
- sequences.append((interleaved, is_audio))
462
- max_seq = max(max_seq, len(interleaved))
463
-
464
- for i, b in enumerate(batch):
465
- w = b["whisper"]
466
- w_pad = F.pad(w, (0, 0, 0, max_w - w.shape[0]))
467
- whisper_batch.append(w_pad)
468
-
469
- interleaved, is_audio = sequences[i]
470
- seq_tensor = torch.tensor(interleaved, dtype=torch.long)
471
- mask_tensor = torch.tensor(is_audio, dtype=torch.bool)
472
-
473
- seq_pad = F.pad(seq_tensor, (0, max_seq - len(interleaved)), value=-100)
474
- mask_pad = F.pad(mask_tensor, (0, max_seq - len(is_audio)), value=False)
475
-
476
- interleaved_batch.append(seq_pad)
477
- is_audio_batch.append(mask_pad)
478
 
479
- return {
480
- "whisper": torch.stack(whisper_batch),
481
- "interleaved": torch.stack(interleaved_batch),
482
- "is_audio_mask": torch.stack(is_audio_batch)
483
- }
484
 
 
485
 
486
- # ============================================================
487
- # Arguments
488
- # ============================================================
489
- def parse_args():
490
- parser = argparse.ArgumentParser(description="Stage 2: Adapter + LoRA with Interleaved Output")
491
- parser.add_argument("--data", type=str, required=True)
492
- parser.add_argument("--output_dir", type=str, default="./checkpoints")
493
- parser.add_argument("--stage1_ckpt", type=str, default=None,
494
- help="Path to Stage 1 adapter checkpoint (required for proper init)")
495
- parser.add_argument("--lr", type=float, default=5e-5)
496
- parser.add_argument("--epochs", type=int, default=3, help="3+ epochs recommended")
497
- parser.add_argument("--batch_size", type=int, default=None)
498
- parser.add_argument("--grad_accum", type=int, default=None)
499
- parser.add_argument("--warmup_ratio", type=float, default=0.03)
500
- parser.add_argument("--max_grad_norm", type=float, default=1.0)
501
- parser.add_argument("--save_steps", type=int, default=200)
502
- parser.add_argument("--label_smoothing", type=float, default=0.1)
503
- # Scheduled interleaving
504
- parser.add_argument("--initial_text_ratio", type=float, default=0.9)
505
- parser.add_argument("--decay_steps", type=int, default=300)
506
- # LoRA config
507
- parser.add_argument("--lora_r", type=int, default=16)
508
- parser.add_argument("--lora_alpha", type=int, default=32)
509
- parser.add_argument("--lora_dropout", type=float, default=0.05)
510
- # Model
511
- parser.add_argument("--model_path", type=str, default="canopylabs/3b-es_it-ft-research_release")
512
- parser.add_argument("--resume", type=str, default=None)
513
- # Memory limits
514
- parser.add_argument("--vram_fraction", type=float, default=0.80, help="VRAM fraction to use (default 0.80)")
515
- parser.add_argument("--ram_limit_gb", type=float, default=None, help="RAM limit in GB (auto if not specified)")
516
- parser.add_argument("--gradient_checkpointing", action="store_true", help="Enable gradient checkpointing to reduce VRAM")
517
- # Modes
518
- parser.add_argument("--demo", action="store_true")
519
- parser.add_argument("--test", action="store_true")
520
- return parser.parse_args()
521
 
 
 
522
 
523
- # ============================================================
524
- # Main
525
- # ============================================================
526
- def main():
527
- args = parse_args()
528
 
529
- # Determine mixed precision based on device
530
- # MPS doesn't support bf16, CUDA/ROCm do
531
- mixed_precision = None
532
- if torch.cuda.is_available():
533
- mixed_precision = "bf16"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
535
- # Initialize Accelerator (auto-detects CUDA, ROCm, MPS, XPU)
536
- accelerator = Accelerator(
537
- gradient_accumulation_steps=args.grad_accum if args.grad_accum else 1,
538
- mixed_precision=mixed_precision,
539
- )
540
 
541
- device = accelerator.device
542
- is_main = accelerator.is_main_process
543
-
544
- # Set seed for reproducibility
545
- set_seed(42)
546
-
547
- # GPU config
548
- gpu_config = auto_detect_gpu_config()
549
- if args.batch_size is None:
550
- args.batch_size = gpu_config["batch_size"]
551
- if args.grad_accum is None:
552
- args.grad_accum = gpu_config["grad_accum"]
553
- accelerator.gradient_accumulation_steps = args.grad_accum
554
-
555
- # Use bf16 for CUDA, fp32 for MPS/others
556
- torch_dtype = torch.bfloat16 if device.type == 'cuda' else torch.float32
557
-
558
- # Get RAM info and set limits
559
- ram_total, ram_available = get_ram_info()
560
- if args.ram_limit_gb is None:
561
- args.ram_limit_gb = ram_total * 0.80
562
- limit_ram_usage(args.ram_limit_gb)
563
-
564
- if is_main:
565
- log("=" * 60)
566
- log("STAGE 2: Adapter + LoRA (Both Trainable) + Interleaved Output")
567
- log("=" * 60)
568
- log(f"Device: {device} ({accelerator.device.type})")
569
- log(f"GPU: {gpu_config['name']} ({gpu_config['vram_gb']}GB)")
570
- log(f"Num processes: {accelerator.num_processes}")
571
- log(f"RAM: {ram_total:.1f}GB total, {ram_available:.1f}GB available")
572
- log(f"Batch: {args.batch_size}, Grad accum: {args.grad_accum}")
573
- log(f"LR: {args.lr}, Epochs: {args.epochs}")
574
- log(f"LoRA: r={args.lora_r}, alpha={args.lora_alpha}")
575
- log(f"Initial text ratio: {args.initial_text_ratio}")
576
- log(f"Decay steps: {args.decay_steps}")
577
-
578
- # Apply memory limits for CUDA
579
- if device.type == 'cuda':
580
- torch.cuda.set_per_process_memory_fraction(args.vram_fraction)
581
- torch.cuda.empty_cache()
582
- torch.backends.cudnn.benchmark = True
583
- torch.set_float32_matmul_precision('high')
584
- if is_main:
585
- log(f"[MEMORY] VRAM limited to {args.vram_fraction*100:.0f}%")
586
- log(f"[MEMORY] RAM limited to {args.ram_limit_gb:.1f}GB")
587
-
588
- # HuggingFace login
589
- hf_token = os.environ.get("HF_TOKEN")
590
- if hf_token:
591
- login(token=hf_token)
592
-
593
- # Load tokenizer
594
- tokenizer = AutoTokenizer.from_pretrained(args.model_path)
595
- if tokenizer.pad_token is None:
596
- tokenizer.pad_token = tokenizer.eos_token
597
-
598
- # Load datasets
599
- data_paths = [p.strip() for p in args.data.split(",")]
600
- all_datasets = []
601
-
602
- if is_main:
603
- log("\nLoading datasets...")
604
-
605
- for path in data_paths:
606
- if os.path.exists(path):
607
- data = torch.load(path, weights_only=False, mmap=True)
608
- dataset = InterleavedDataset(data, tokenizer)
609
- all_datasets.append(dataset)
610
- if is_main:
611
- log(f" {os.path.basename(path)}: {len(data):,} samples")
612
-
613
- if len(all_datasets) == 0:
614
- raise ValueError("No datasets loaded!")
615
-
616
- combined_dataset = ConcatDataset(all_datasets) if len(all_datasets) > 1 else all_datasets[0]
617
-
618
- # Demo/Test mode
619
- if args.test:
620
- combined_dataset = torch.utils.data.Subset(combined_dataset, range(min(5, len(combined_dataset))))
621
- args.batch_size = min(args.batch_size, len(combined_dataset))
622
- args.grad_accum = 1
623
- elif args.demo:
624
- combined_dataset = torch.utils.data.Subset(combined_dataset, range(min(1000, len(combined_dataset))))
625
- args.batch_size = min(4, args.batch_size)
626
- args.grad_accum = max(8, args.grad_accum)
627
-
628
- if is_main:
629
- log(f"Total samples: {len(combined_dataset):,}")
630
-
631
- # Load LLM with LoRA
632
- if is_main:
633
- log(f"\nLoading LLM with LoRA: {args.model_path}")
634
-
635
- llm = AutoModelForCausalLM.from_pretrained(
636
- args.model_path,
637
- torch_dtype=torch_dtype,
638
- attn_implementation="sdpa",
639
- )
640
 
641
- # Apply LoRA
642
- lora_config = LoraConfig(
643
- r=args.lora_r,
644
- lora_alpha=args.lora_alpha,
645
- target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
646
- lora_dropout=args.lora_dropout,
647
- bias="none",
648
- task_type=TaskType.CAUSAL_LM
649
- )
650
- llm = get_peft_model(llm, lora_config)
651
-
652
- # Auto-detect gradient checkpointing based on VRAM
653
- # Enable if VRAM < 20GB (needed for Stage 2 with LoRA)
654
- use_gradient_checkpointing = args.gradient_checkpointing
655
- if not use_gradient_checkpointing and torch.cuda.is_available():
656
- vram_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3
657
- if vram_gb < 20:
658
- use_gradient_checkpointing = True
659
- if is_main:
660
- log(f"[AUTO] Enabling gradient checkpointing (VRAM={vram_gb:.1f}GB < 20GB)")
661
- else:
662
- if is_main:
663
- log(f"[AUTO] Gradient checkpointing disabled for speed (VRAM={vram_gb:.1f}GB >= 20GB)")
664
-
665
- if use_gradient_checkpointing:
666
- llm.gradient_checkpointing_enable()
667
- if is_main:
668
- log("[MEMORY] Gradient checkpointing enabled")
669
-
670
- if is_main:
671
- llm.print_trainable_parameters()
672
-
673
- # Create adapter (TRAINABLE - same architecture as Stage 1)
674
- adapter = SpeechAdapter(
675
- whisper_dim=1280,
676
- llm_dim=3072,
677
- downsample=5,
678
- intermediate_dim=2048
679
- ).to(dtype=torch_dtype)
680
-
681
- # Load Stage 1 adapter checkpoint
682
- if args.stage1_ckpt and os.path.exists(args.stage1_ckpt):
683
- if is_main:
684
- log(f"\nLoading Stage 1 adapter from: {args.stage1_ckpt}")
685
- ckpt = torch.load(args.stage1_ckpt, map_location="cpu", weights_only=False)
686
- if "adapter" in ckpt:
687
- adapter.load_state_dict(ckpt["adapter"])
688
- if is_main:
689
- log(" Adapter weights loaded successfully!")
690
- if "loss" in ckpt:
691
- log(f" Stage 1 final loss: {ckpt['loss']:.4f}")
692
- if "text_ratio" in ckpt:
693
- log(f" Stage 1 final text_ratio: {ckpt['text_ratio']:.1f}")
694
- else:
695
- if is_main:
696
- log(" WARNING: No 'adapter' key in checkpoint, using random init")
697
- else:
698
- if is_main:
699
- log("\nWARNING: No Stage 1 checkpoint provided, adapter starting from random init")
700
- log(" Recommended: --stage1_ckpt checkpoints/stage1_best.pt")
701
-
702
- adapter_params = sum(p.numel() for p in adapter.parameters())
703
- lora_params = sum(p.numel() for p in llm.parameters() if p.requires_grad)
704
-
705
- if is_main:
706
- log(f"\nTrainable parameters:")
707
- log(f" Adapter: {adapter_params:,} ({adapter_params/1e6:.1f}M)")
708
- log(f" LoRA: {lora_params:,} ({lora_params/1e6:.1f}M)")
709
- log(f" Total: {adapter_params + lora_params:,} ({(adapter_params + lora_params)/1e6:.1f}M)")
710
-
711
- # Optimizer (Adapter + LoRA together)
712
- all_params = list(adapter.parameters()) + [p for p in llm.parameters() if p.requires_grad]
713
- optimizer = torch.optim.AdamW(all_params, lr=args.lr, weight_decay=0.01)
714
-
715
- # Training state
716
- global_step = 0
717
- start_epoch = 0
718
- best_loss = float("inf")
719
- current_text_ratio = args.initial_text_ratio
720
-
721
- # Resume from Stage 2 checkpoint
722
- if args.resume and os.path.exists(args.resume):
723
- if is_main:
724
- log(f"\nResuming from: {args.resume}")
725
- ckpt = torch.load(args.resume, map_location="cpu", weights_only=False)
726
  if "adapter" in ckpt:
727
- adapter.load_state_dict(ckpt["adapter"])
728
  if "lora" in ckpt:
729
- llm.load_state_dict(ckpt["lora"], strict=False)
 
730
  if "optimizer" in ckpt:
731
- optimizer.load_state_dict(ckpt["optimizer"])
732
  if "step" in ckpt:
733
- global_step = ckpt["step"]
734
  if "epoch" in ckpt:
735
- start_epoch = ckpt["epoch"]
736
  if "text_ratio" in ckpt:
737
- current_text_ratio = ckpt["text_ratio"]
738
- if "loss" in ckpt:
739
- best_loss = ckpt["loss"]
740
 
741
- # Training
742
- os.makedirs(args.output_dir, exist_ok=True)
743
-
744
- # Create dataloader with dynamic collate
745
- def collate_with_ratio(batch):
746
- current_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
747
- return collate_fn(batch, current_ratio, tokenizer=tokenizer)
748
-
749
- train_loader = DataLoader(
750
- combined_dataset,
751
- batch_size=args.batch_size,
752
- shuffle=True,
753
- collate_fn=collate_with_ratio,
754
- num_workers=4,
755
- pin_memory=True
756
- )
757
 
758
- # Prepare with accelerator
759
- adapter, llm, optimizer, train_loader = accelerator.prepare(
760
- adapter, llm, optimizer, train_loader
761
- )
 
 
 
762
 
763
- steps_per_epoch = max(1, len(train_loader) // args.grad_accum)
764
- total_steps = max(1, steps_per_epoch * args.epochs)
765
- warmup_steps = int(total_steps * args.warmup_ratio)
 
 
 
 
 
 
 
 
 
766
 
767
- scheduler = CosineAnnealingLR(optimizer, T_max=max(1, total_steps - warmup_steps), eta_min=1e-6)
 
768
 
769
- if is_main:
770
- log(f"Steps per epoch: {steps_per_epoch}, Total: {total_steps}, Warmup: {warmup_steps}")
 
 
 
 
 
771
 
772
- if is_main:
773
- log("\n" + "=" * 60)
774
- log("STARTING STAGE 2 TRAINING")
775
- log("=" * 60)
776
 
777
- for epoch in range(start_epoch, args.epochs):
778
- # Update text ratio based on global step
779
- current_text_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
780
 
781
- # Get unwrapped models for forward pass
782
- unwrapped_adapter = accelerator.unwrap_model(adapter)
783
- unwrapped_llm = accelerator.unwrap_model(llm)
784
 
785
- adapter.train()
786
- llm.train()
787
- epoch_loss = 0
788
- accum_loss = 0
789
 
790
- pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{args.epochs}", disable=not is_main)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
 
792
- for batch_idx, batch in enumerate(pbar):
793
- # Update text ratio dynamically
794
- current_text_ratio = get_text_ratio(global_step, args.decay_steps, args.initial_text_ratio)
795
 
796
- whisper = batch["whisper"]
797
- interleaved = batch["interleaved"]
 
 
 
798
 
799
- # Use gradient accumulation context
800
- with accelerator.accumulate(adapter, llm):
801
- # Forward through adapter
802
- audio_embeds = adapter(whisper)
803
 
804
- # Get token embeddings for interleaved sequence (teacher forcing)
805
- input_tokens = interleaved[:, :-1].clamp(min=0)
806
- with torch.no_grad():
807
- # Access base model's embed_tokens through PEFT wrapper
808
- token_embeds = unwrapped_llm.get_base_model().model.embed_tokens(input_tokens)
809
 
810
- # Combine: [audio_embeds] + [token_embeds]
811
- combined = torch.cat([audio_embeds, token_embeds], dim=1)
 
 
 
 
 
 
 
 
812
 
813
- # Forward through LLM with LoRA
814
- outputs = llm(inputs_embeds=combined, use_cache=False)
815
- logits = outputs.logits
 
816
 
817
- # Loss: predict interleaved tokens after audio prefix
818
- audio_len = audio_embeds.shape[1]
819
- seq_len = interleaved.shape[1]
 
 
820
 
821
- seq_logits = logits[:, audio_len-1:audio_len-1+seq_len]
822
- targets = interleaved
 
823
 
824
- loss = F.cross_entropy(
825
- seq_logits.reshape(-1, logits.size(-1)),
826
- targets.reshape(-1),
827
- ignore_index=-100,
828
- label_smoothing=args.label_smoothing
829
- )
830
 
831
- accelerator.backward(loss)
832
- accum_loss += loss.item()
 
 
833
 
834
- # Clip gradients and step
835
- if accelerator.sync_gradients:
836
- accelerator.clip_grad_norm_(all_params, args.max_grad_norm)
837
 
838
- optimizer.step()
839
- optimizer.zero_grad()
840
 
841
- # Update after accumulation
842
- if accelerator.sync_gradients:
843
- if global_step < warmup_steps:
844
- lr_scale = (global_step + 1) / warmup_steps
845
- for pg in optimizer.param_groups:
846
- pg["lr"] = args.lr * lr_scale
847
- else:
848
- scheduler.step()
849
 
850
- global_step += 1
851
- epoch_loss += accum_loss
 
852
 
853
- pbar.set_postfix(
854
- loss=f"{accum_loss:.4f}",
855
- text_ratio=f"{current_text_ratio:.1f}",
856
- lr=f"{optimizer.param_groups[0]['lr']:.2e}"
857
- )
858
 
859
- # Save checkpoint
860
- if global_step % args.save_steps == 0 and is_main:
861
- accelerator.wait_for_everyone()
862
- unwrapped_adapter = accelerator.unwrap_model(adapter)
863
- unwrapped_llm = accelerator.unwrap_model(llm)
864
- ckpt_path = os.path.join(args.output_dir, f"stage2_step{global_step}.pt")
865
- save_checkpoint_async({
866
- "adapter": unwrapped_adapter.state_dict(),
867
- "lora": unwrapped_llm.state_dict(),
868
- "optimizer": optimizer.state_dict(),
869
- "step": global_step,
870
- "epoch": epoch,
871
- "loss": accum_loss,
872
- "text_ratio": current_text_ratio
873
- }, ckpt_path, is_main)
874
-
875
- accum_loss = 0
876
-
877
- # Epoch end - divide by grad_accum to get per-batch average
878
- avg_loss = epoch_loss / max(1, steps_per_epoch) / args.grad_accum
879
-
880
- if is_main:
881
- log(f"Epoch {epoch+1} avg loss: {avg_loss:.4f}, text_ratio: {current_text_ratio:.1f}")
882
-
883
- accelerator.wait_for_everyone()
884
- unwrapped_adapter = accelerator.unwrap_model(adapter)
885
- unwrapped_llm = accelerator.unwrap_model(llm)
886
- ckpt_path = os.path.join(args.output_dir, f"stage2_epoch{epoch+1}.pt")
887
- save_checkpoint_async({
888
- "adapter": unwrapped_adapter.state_dict(),
889
- "lora": unwrapped_llm.state_dict(),
890
- "optimizer": optimizer.state_dict(),
891
- "step": global_step,
892
- "epoch": epoch + 1,
893
- "loss": avg_loss,
894
- "text_ratio": current_text_ratio
895
- }, ckpt_path, is_main)
896
-
897
- if avg_loss < best_loss:
898
- best_loss = avg_loss
899
- best_path = os.path.join(args.output_dir, "stage2_best.pt")
900
- save_checkpoint_async({
901
- "adapter": unwrapped_adapter.state_dict(),
902
- "lora": unwrapped_llm.state_dict(),
903
- "step": global_step,
904
- "epoch": epoch + 1,
905
- "loss": best_loss,
906
- "text_ratio": current_text_ratio
907
- }, best_path, is_main)
908
- log(f"Best model saved! Loss: {best_loss:.4f}")
909
-
910
- # Finish
911
- accelerator.wait_for_everyone()
912
- if is_main:
913
- wait_for_checkpoints()
914
- log("\n" + "=" * 60)
915
- log("STAGE 2 COMPLETE!")
916
- log(f"Best loss: {best_loss:.4f}")
917
- log(f"Final text_ratio: {current_text_ratio:.1f}")
918
- log("=" * 60)
919
- log("\nCheckpoints saved:")
920
- log(f" Best: {args.output_dir}/stage2_best.pt")
921
- log(f" Last: {args.output_dir}/stage2_epoch{args.epochs}.pt")
922
 
923
 
924
  if __name__ == "__main__":
 
9
  - Continues scheduled interleaving (90% text -> 0% text)
10
 
11
  Usage:
12
+ python passo3_finetune_stage2.py --data data.pt --stage1_ckpt checkpoints/stage1_best.pt --epochs 3
13
 
14
  Based on IST-LM paper + LLaMA-Omni 2 staging approach.
15
  """
16
 
 
 
17
  import argparse
18
  import torch
19
+ from typing import Dict, Any
20
+ from itertools import chain
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ from training.config import TrainingConfig, GPUConfig, LoRAConfig
23
+ from training.models import SpeechAdapter, ModelFactory
24
+ from training.checkpoint import Stage2CheckpointManager, TrainingState
25
+ from training.trainer import BaseTrainer
26
+ from training.utils import log, should_enable_gradient_checkpointing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
 
29
+ class Stage2Trainer(BaseTrainer):
 
 
 
30
  """
31
+ Stage 2 Trainer: Adapter + LoRA together.
 
 
32
 
33
+ Continues from Stage 1, adding LoRA to the LLM and training
34
+ both adapter and LoRA jointly.
35
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ def __init__(self, config: TrainingConfig, stage1_ckpt: str, lora_config: LoRAConfig = None):
38
+ super().__init__(config)
39
+ self.stage1_ckpt = stage1_ckpt
40
+ self.lora_config = lora_config or LoRAConfig()
 
 
 
41
 
42
+ def _get_stage_name(self) -> str:
43
+ return "STAGE 2: Adapter + LoRA (Joint Training)"
44
 
45
+ def _setup_models(self):
46
+ """Setup adapter and LLM with LoRA."""
47
+ gpu_config = GPUConfig.auto_detect()
48
+ dtype = gpu_config.dtype
49
 
50
+ # Check gradient checkpointing
51
+ use_checkpointing = self.config.gradient_checkpointing or \
52
+ should_enable_gradient_checkpointing(
53
+ gpu_config.vram_gb,
54
+ self.config.dynamic_decay
55
+ )
56
 
57
+ if self.is_main:
58
+ log(f"\nLoading LLM: {self.config.model_path}")
59
 
60
+ # Load LLM (not frozen, will apply LoRA)
61
+ self.llm = ModelFactory.create_llm(
62
+ self.config.model_path,
63
+ dtype=dtype,
64
+ freeze=False,
65
+ gradient_checkpointing=use_checkpointing,
 
 
66
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
+ # Apply LoRA
69
+ if self.is_main:
70
+ log(f"Applying LoRA (r={self.lora_config.r}, alpha={self.lora_config.alpha})")
 
 
71
 
72
+ self.llm = ModelFactory.apply_lora(self.llm, self.lora_config)
73
 
74
+ if self.is_main and use_checkpointing:
75
+ log("[Memory] Gradient checkpointing enabled")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
+ # Create and load adapter from Stage 1
78
+ self.adapter = SpeechAdapter().to(dtype=dtype)
79
 
80
+ if self.is_main:
81
+ log(f"\nLoading adapter from Stage 1: {self.stage1_ckpt}")
 
 
 
82
 
83
+ ckpt = torch.load(self.stage1_ckpt, map_location="cpu", weights_only=False)
84
+ if "adapter" in ckpt:
85
+ self.adapter.load_state_dict(ckpt["adapter"])
86
+ else:
87
+ self.adapter.load_state_dict(ckpt)
88
+
89
+ # Log parameters
90
+ if self.is_main:
91
+ adapter_params = self.adapter.get_num_params()
92
+ lora_params = sum(p.numel() for p in self.llm.parameters() if p.requires_grad)
93
+ total_params = adapter_params + lora_params
94
+
95
+ log(f"\nTrainable parameters:")
96
+ log(f" Adapter: {adapter_params:,} ({adapter_params/1e6:.1f}M)")
97
+ log(f" LoRA: {lora_params:,} ({lora_params/1e6:.1f}M)")
98
+ log(f" Total: {total_params:,} ({total_params/1e6:.1f}M)")
99
+
100
+ def _setup_optimizer(self):
101
+ """Setup optimizer for adapter + LoRA."""
102
+ trainable_params = chain(
103
+ self.adapter.parameters(),
104
+ (p for p in self.llm.parameters() if p.requires_grad)
105
+ )
106
 
107
+ self.optimizer = torch.optim.AdamW(
108
+ trainable_params,
109
+ lr=self.config.learning_rate,
110
+ weight_decay=self.config.weight_decay,
111
+ )
112
 
113
+ def _setup_checkpoint_manager(self):
114
+ """Setup Stage 2 checkpoint manager."""
115
+ self.checkpoint_manager = Stage2CheckpointManager(
116
+ self.config.output_dir,
117
+ verbose=self.is_main,
118
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
+ def _load_checkpoint(self, ckpt: Dict[str, Any]):
121
+ """Load checkpoint state."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  if "adapter" in ckpt:
123
+ self.adapter.load_state_dict(ckpt["adapter"])
124
  if "lora" in ckpt:
125
+ # Load LoRA weights
126
+ self.llm.load_state_dict(ckpt["lora"], strict=False)
127
  if "optimizer" in ckpt:
128
+ self.optimizer.load_state_dict(ckpt["optimizer"])
129
  if "step" in ckpt:
130
+ self.global_step = ckpt["step"]
131
  if "epoch" in ckpt:
132
+ self.start_epoch = ckpt["epoch"]
133
  if "text_ratio" in ckpt:
134
+ self.current_text_ratio = ckpt["text_ratio"]
 
 
135
 
136
+ def _get_trainable_params(self):
137
+ """Get trainable parameters (adapter + LoRA)."""
138
+ return chain(
139
+ self.adapter.parameters(),
140
+ (p for p in self.llm.parameters() if p.requires_grad)
141
+ )
 
 
 
 
 
 
 
 
 
 
142
 
143
+ def _get_lora_state_dict(self) -> Dict[str, Any]:
144
+ """Get LoRA state dict."""
145
+ unwrapped_llm = self.accelerator.unwrap_model(self.llm)
146
+ return {
147
+ k: v for k, v in unwrapped_llm.state_dict().items()
148
+ if "lora" in k.lower()
149
+ }
150
 
151
+ def _save_step_checkpoint(self, loss: float):
152
+ """Save step checkpoint."""
153
+ self.accelerator.wait_for_everyone()
154
+ unwrapped_adapter = self.accelerator.unwrap_model(self.adapter)
155
+
156
+ state = TrainingState(
157
+ step=self.global_step,
158
+ epoch=self.start_epoch,
159
+ loss=loss,
160
+ text_ratio=self.current_text_ratio,
161
+ best_loss=self.best_loss,
162
+ )
163
 
164
+ # Get LoRA state
165
+ lora_state = self._get_lora_state_dict()
166
 
167
+ # Save checkpoint
168
+ state_dict = {
169
+ "adapter": unwrapped_adapter.state_dict(),
170
+ "lora": lora_state,
171
+ "optimizer": self.optimizer.state_dict(),
172
+ **state.to_dict()
173
+ }
174
 
175
+ self.checkpoint_manager.save(
176
+ state_dict,
177
+ f"stage2_step{self.global_step}.pt",
178
+ )
179
 
180
+ def _finish_epoch(self, epoch: int, epoch_loss: float):
181
+ """Finish epoch and save checkpoints."""
182
+ avg_loss = epoch_loss / max(1, self.steps_per_epoch) / self.config.grad_accum
183
 
184
+ if self.is_main:
185
+ log(f"Epoch {epoch+1} avg loss: {avg_loss:.4f}, text_ratio: {self.current_text_ratio:.1f}")
 
186
 
187
+ self.accelerator.wait_for_everyone()
188
+ unwrapped_adapter = self.accelerator.unwrap_model(self.adapter)
189
+ lora_state = self._get_lora_state_dict()
 
190
 
191
+ state = TrainingState(
192
+ step=self.global_step,
193
+ epoch=epoch + 1,
194
+ loss=avg_loss,
195
+ text_ratio=self.current_text_ratio,
196
+ best_loss=self.best_loss,
197
+ )
198
+
199
+ # Save epoch checkpoint
200
+ state_dict = {
201
+ "adapter": unwrapped_adapter.state_dict(),
202
+ "lora": lora_state,
203
+ "optimizer": self.optimizer.state_dict(),
204
+ **state.to_dict()
205
+ }
206
+
207
+ self.checkpoint_manager.save(
208
+ state_dict,
209
+ f"stage2_epoch{epoch+1}.pt",
210
+ )
211
+
212
+ # Save best if improved
213
+ if avg_loss < self.best_loss:
214
+ self.best_loss = avg_loss
215
+ state.best_loss = self.best_loss
216
+
217
+ self.checkpoint_manager.save_best(
218
+ unwrapped_adapter.state_dict(),
219
+ state,
220
+ lora_state=lora_state,
221
+ )
222
+ log(f"Best model saved! Loss: {self.best_loss:.4f}")
223
 
 
 
 
224
 
225
+ def parse_args():
226
+ """Parse command line arguments."""
227
+ parser = argparse.ArgumentParser(
228
+ description="Stage 2: Adapter + LoRA with Interleaved Output"
229
+ )
230
 
231
+ # Data
232
+ parser.add_argument("--data", type=str, required=True,
233
+ help="Dataset path(s), comma-separated")
234
+ parser.add_argument("--output_dir", type=str, default="./checkpoints")
235
 
236
+ # Stage 1 checkpoint (required)
237
+ parser.add_argument("--stage1_ckpt", type=str, required=True,
238
+ help="Path to Stage 1 best checkpoint")
 
 
239
 
240
+ # Training
241
+ parser.add_argument("--lr", type=float, default=5e-5)
242
+ parser.add_argument("--epochs", type=int, default=3,
243
+ help="3+ epochs for joint training")
244
+ parser.add_argument("--batch_size", type=int, default=None)
245
+ parser.add_argument("--grad_accum", type=int, default=None)
246
+ parser.add_argument("--warmup_ratio", type=float, default=0.03)
247
+ parser.add_argument("--max_grad_norm", type=float, default=1.0)
248
+ parser.add_argument("--label_smoothing", type=float, default=0.1)
249
+ parser.add_argument("--max_seq_len", type=int, default=2048)
250
 
251
+ # LoRA
252
+ parser.add_argument("--lora_r", type=int, default=16)
253
+ parser.add_argument("--lora_alpha", type=int, default=32)
254
+ parser.add_argument("--lora_dropout", type=float, default=0.05)
255
 
256
+ # Scheduled interleaving
257
+ parser.add_argument("--initial_text_ratio", type=float, default=0.9)
258
+ parser.add_argument("--decay_steps", type=int, default=300)
259
+ parser.add_argument("--dynamic_decay", action="store_true")
260
+ parser.add_argument("--final_audio_portion", type=float, default=0.2)
261
 
262
+ # Model
263
+ parser.add_argument("--model_path", type=str,
264
+ default="canopylabs/3b-es_it-ft-research_release")
265
 
266
+ # Checkpointing
267
+ parser.add_argument("--save_steps", type=int, default=200)
268
+ parser.add_argument("--resume", type=str, default=None)
 
 
 
269
 
270
+ # Memory
271
+ parser.add_argument("--vram_fraction", type=float, default=0.80)
272
+ parser.add_argument("--ram_limit_gb", type=float, default=None)
273
+ parser.add_argument("--gradient_checkpointing", action="store_true")
274
 
275
+ # Modes
276
+ parser.add_argument("--demo", action="store_true")
277
+ parser.add_argument("--test", action="store_true")
278
 
279
+ return parser.parse_args()
 
280
 
 
 
 
 
 
 
 
 
281
 
282
+ def main():
283
+ args = parse_args()
284
+ config = TrainingConfig.from_args(args)
285
 
286
+ lora_config = LoRAConfig(
287
+ r=args.lora_r,
288
+ alpha=args.lora_alpha,
289
+ dropout=args.lora_dropout,
290
+ )
291
 
292
+ trainer = Stage2Trainer(config, args.stage1_ckpt, lora_config)
293
+ trainer.setup()
294
+ trainer.train()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
 
297
  if __name__ == "__main__":
passo4_inference.py CHANGED
@@ -14,12 +14,12 @@ from pathlib import Path
14
 
15
  # SNAC token configuration
16
  SNAC_BASE = 128266
 
17
  EOS_TOKEN = 128009
18
 
19
  def load_models(checkpoint_path: str, device: str = "cuda"):
20
  """Load all models for inference."""
21
  from transformers import WhisperModel, WhisperFeatureExtractor, AutoTokenizer
22
- from peft import PeftModel
23
  import snac
24
 
25
  print("Loading models...")
@@ -34,8 +34,11 @@ def load_models(checkpoint_path: str, device: str = "cuda"):
34
  print(f" Loading checkpoint: {checkpoint_path}")
35
  checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
36
 
 
 
 
37
  # Load LLM with LoRA
38
- print(" Loading LLM with LoRA...")
39
  from transformers import AutoModelForCausalLM
40
 
41
  llm = AutoModelForCausalLM.from_pretrained(
@@ -45,20 +48,41 @@ def load_models(checkpoint_path: str, device: str = "cuda"):
45
  )
46
 
47
  # Load LoRA weights if present
48
- if 'lora' in checkpoint or 'lora_state_dict' in checkpoint:
49
- print(" Loading LoRA weights...")
50
- from peft import LoraConfig, get_peft_model
51
- lora_config = LoraConfig(
52
- r=16,
53
- lora_alpha=32,
54
- target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
55
- lora_dropout=0.05,
56
- bias="none",
57
- task_type="CAUSAL_LM"
58
- )
59
- llm = get_peft_model(llm, lora_config)
60
- lora_key = 'lora' if 'lora' in checkpoint else 'lora_state_dict'
61
- llm.load_state_dict(checkpoint[lora_key], strict=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  llm.eval()
64
 
@@ -71,9 +95,18 @@ def load_models(checkpoint_path: str, device: str = "cuda"):
71
  downsample=5
72
  ).to(device)
73
 
74
- adapter_key = 'adapter' if 'adapter' in checkpoint else 'adapter_state_dict'
75
- if adapter_key in checkpoint:
76
- adapter.load_state_dict(checkpoint[adapter_key])
 
 
 
 
 
 
 
 
 
77
  adapter.eval()
78
 
79
  # Load SNAC decoder
@@ -84,6 +117,12 @@ def load_models(checkpoint_path: str, device: str = "cuda"):
84
  # Load tokenizer
85
  tokenizer = AutoTokenizer.from_pretrained("canopylabs/3b-es_it-ft-research_release")
86
 
 
 
 
 
 
 
87
  print("Models loaded!")
88
  return whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer
89
 
@@ -109,39 +148,60 @@ def encode_audio(audio_path: str, whisper_model, feature_extractor, adapter, dev
109
 
110
 
111
  def decode_snac_tokens(tokens: list, snac_model, device: str):
112
- """Decode SNAC tokens to audio waveform."""
113
- # Remove offsets and reshape
114
- # SNAC has 3 layers with 1:2:4 ratio
 
 
 
 
 
 
 
 
 
 
 
115
  layer0_tokens = []
116
  layer1_tokens = []
117
  layer2_tokens = []
118
 
119
- # Parse interleaved tokens: 1 from layer0, 2 from layer1, 4 from layer2 per frame
120
- i = 0
121
- while i < len(tokens):
122
- if i < len(tokens):
123
- layer0_tokens.append(tokens[i] - SNAC_BASE)
124
- i += 1
125
- if i < len(tokens):
126
- layer1_tokens.append(tokens[i] - SNAC_BASE - 4096)
127
- i += 1
128
- if i < len(tokens):
129
- layer1_tokens.append(tokens[i] - SNAC_BASE - 4096)
130
- i += 1
131
- for _ in range(4):
132
- if i < len(tokens):
133
- layer2_tokens.append(tokens[i] - SNAC_BASE - 8192)
134
- i += 1
135
-
136
- # Create tensors
137
- min_len = min(len(layer0_tokens), len(layer1_tokens) // 2, len(layer2_tokens) // 4)
138
- if min_len == 0:
139
- return np.zeros(24000, dtype=np.float32) # 1 second of silence
 
 
 
 
 
 
 
 
 
 
140
 
141
  codes = [
142
- torch.tensor([layer0_tokens[:min_len]], dtype=torch.long, device=device),
143
- torch.tensor([layer1_tokens[:min_len * 2]], dtype=torch.long, device=device),
144
- torch.tensor([layer2_tokens[:min_len * 4]], dtype=torch.long, device=device)
145
  ]
146
 
147
  # Decode
@@ -156,45 +216,104 @@ def generate_response(
156
  whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer,
157
  device: str,
158
  max_new_tokens: int = 500,
159
- temperature: float = 0.7
 
 
160
  ):
161
  """Generate speech response from audio input."""
162
 
163
  # Encode input audio
164
  print("Encoding input audio...")
165
  audio_embeddings = encode_audio(audio_input, whisper_model, feature_extractor, adapter, device)
 
166
 
167
  # Generate with LLM
168
  print("Generating response...")
169
 
170
- # Create input embeds by concatenating audio embeddings with start tokens
171
  with torch.no_grad():
172
  # Get embeddings layer
173
  embed_layer = llm.get_input_embeddings()
174
 
175
- # Add start token
176
- start_tokens = tokenizer.encode("<|start|>", add_special_tokens=False, return_tensors="pt").to(device)
177
- start_embeds = embed_layer(start_tokens)
178
-
179
- # Concatenate: audio + start (ensure same dtype)
180
- input_embeds = torch.cat([audio_embeddings.to(torch.bfloat16), start_embeds.to(torch.bfloat16)], dim=1)
181
-
182
- # Generate
183
- outputs = llm.generate(
184
- inputs_embeds=input_embeds,
185
- max_new_tokens=max_new_tokens,
186
- temperature=temperature,
187
- do_sample=True,
188
- top_p=0.9,
189
- pad_token_id=tokenizer.pad_token_id,
190
- eos_token_id=EOS_TOKEN
191
- )
192
-
193
- # Extract SNAC tokens from output
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  generated_tokens = outputs[0].tolist()
195
- snac_tokens = [t for t in generated_tokens if SNAC_BASE <= t < SNAC_BASE + 3 * 4096]
196
 
197
- print(f"Generated {len(snac_tokens)} SNAC tokens")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
  # Decode to audio
200
  print("Decoding to audio...")
@@ -208,8 +327,10 @@ def main():
208
  parser.add_argument("--checkpoint", type=str, required=True, help="Path to model checkpoint")
209
  parser.add_argument("--input", type=str, required=True, help="Input audio file")
210
  parser.add_argument("--output", type=str, default="output.wav", help="Output audio file")
211
- parser.add_argument("--max_tokens", type=int, default=500, help="Max tokens to generate")
212
- parser.add_argument("--temperature", type=float, default=0.7, help="Sampling temperature")
 
 
213
  args = parser.parse_args()
214
 
215
  device = "cuda" if torch.cuda.is_available() else "cpu"
@@ -226,7 +347,9 @@ def main():
226
  whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer,
227
  device,
228
  max_new_tokens=args.max_tokens,
229
- temperature=args.temperature
 
 
230
  )
231
 
232
  # Save output
 
14
 
15
  # SNAC token configuration
16
  SNAC_BASE = 128266
17
+ SNAC_MAX = SNAC_BASE + 7 * 4096 # 7 positions per frame, 4096 tokens each
18
  EOS_TOKEN = 128009
19
 
20
  def load_models(checkpoint_path: str, device: str = "cuda"):
21
  """Load all models for inference."""
22
  from transformers import WhisperModel, WhisperFeatureExtractor, AutoTokenizer
 
23
  import snac
24
 
25
  print("Loading models...")
 
34
  print(f" Loading checkpoint: {checkpoint_path}")
35
  checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
36
 
37
+ # Debug: show checkpoint keys
38
+ print(f" Checkpoint keys: {list(checkpoint.keys())}")
39
+
40
  # Load LLM with LoRA
41
+ print(" Loading LLM...")
42
  from transformers import AutoModelForCausalLM
43
 
44
  llm = AutoModelForCausalLM.from_pretrained(
 
48
  )
49
 
50
  # Load LoRA weights if present
51
+ lora_loaded = False
52
+ for lora_key in ['lora', 'lora_state_dict']:
53
+ if lora_key in checkpoint:
54
+ print(f" Found LoRA weights with key '{lora_key}'")
55
+ from peft import LoraConfig, get_peft_model
56
+ lora_config = LoraConfig(
57
+ r=16,
58
+ lora_alpha=32,
59
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
60
+ lora_dropout=0.0, # No dropout for inference
61
+ bias="none",
62
+ task_type="CAUSAL_LM"
63
+ )
64
+ llm = get_peft_model(llm, lora_config)
65
+
66
+ # Load the LoRA state dict
67
+ lora_state = checkpoint[lora_key]
68
+ print(f" LoRA state dict has {len(lora_state)} keys")
69
+ if len(lora_state) > 0:
70
+ print(f" Sample keys: {list(lora_state.keys())[:3]}")
71
+
72
+ # Try to load with strict=False to see what matches
73
+ result = llm.load_state_dict(lora_state, strict=False)
74
+ print(f" LoRA load - Missing: {len(result.missing_keys)}, Unexpected: {len(result.unexpected_keys)}")
75
+ if result.missing_keys:
76
+ print(f" Missing keys (first 3): {result.missing_keys[:3]}")
77
+ if result.unexpected_keys:
78
+ print(f" Unexpected keys (first 3): {result.unexpected_keys[:3]}")
79
+
80
+ lora_loaded = True
81
+ break
82
+
83
+ if not lora_loaded:
84
+ print(" WARNING: No LoRA weights found in checkpoint! Model will use base weights only.")
85
+ print(" This might result in no SNAC token generation.")
86
 
87
  llm.eval()
88
 
 
95
  downsample=5
96
  ).to(device)
97
 
98
+ adapter_loaded = False
99
+ for adapter_key in ['adapter', 'adapter_state_dict']:
100
+ if adapter_key in checkpoint:
101
+ print(f" Found adapter weights with key '{adapter_key}'")
102
+ result = adapter.load_state_dict(checkpoint[adapter_key], strict=False)
103
+ print(f" Adapter load - Missing: {len(result.missing_keys)}, Unexpected: {len(result.unexpected_keys)}")
104
+ adapter_loaded = True
105
+ break
106
+
107
+ if not adapter_loaded:
108
+ print(" WARNING: No adapter weights found in checkpoint!")
109
+
110
  adapter.eval()
111
 
112
  # Load SNAC decoder
 
117
  # Load tokenizer
118
  tokenizer = AutoTokenizer.from_pretrained("canopylabs/3b-es_it-ft-research_release")
119
 
120
+ # Debug tokenizer info
121
+ print(f" Tokenizer vocab size: {tokenizer.vocab_size}")
122
+ print(f" BOS token: {tokenizer.bos_token} (id={tokenizer.bos_token_id})")
123
+ print(f" EOS token: {tokenizer.eos_token} (id={tokenizer.eos_token_id})")
124
+ print(f" SNAC token range: {SNAC_BASE} to {SNAC_BASE + 3*4096 - 1}")
125
+
126
  print("Models loaded!")
127
  return whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer
128
 
 
148
 
149
 
150
  def decode_snac_tokens(tokens: list, snac_model, device: str):
151
+ """Decode SNAC tokens to audio waveform.
152
+
153
+ SNAC uses 3 hierarchical layers with 1:2:4 ratio.
154
+ Each "frame" has 7 tokens in order:
155
+ - 1 token from layer 0 (position 0)
156
+ - 2 tokens from layer 1 (positions 1, 2)
157
+ - 4 tokens from layer 2 (positions 3, 4, 5, 6)
158
+
159
+ Tokens are offset by: SNAC_BASE + (position % 7) * 4096
160
+ """
161
+ if len(tokens) == 0:
162
+ print(" Warning: No SNAC tokens to decode")
163
+ return np.zeros(24000, dtype=np.float32) # 1 second of silence
164
+
165
  layer0_tokens = []
166
  layer1_tokens = []
167
  layer2_tokens = []
168
 
169
+ # Parse tokens by removing position-based offsets
170
+ for i, tok in enumerate(tokens):
171
+ pos = i % 7
172
+ # Remove the offset to get the original code
173
+ original = tok - SNAC_BASE - (pos * 4096)
174
+
175
+ # Ensure codes are valid (0-4095)
176
+ if original < 0 or original >= 4096:
177
+ print(f" Warning: Invalid code {original} at position {i} (token={tok}, pos={pos})")
178
+ original = max(0, min(4095, original))
179
+
180
+ if pos == 0:
181
+ layer0_tokens.append(original)
182
+ elif pos in [1, 2]:
183
+ layer1_tokens.append(original)
184
+ else: # pos in [3, 4, 5, 6]
185
+ layer2_tokens.append(original)
186
+
187
+ # Calculate how many complete frames we have
188
+ n_frames = len(tokens) // 7
189
+
190
+ if n_frames == 0:
191
+ print(f" Warning: Not enough tokens for a complete frame ({len(tokens)} tokens)")
192
+ return np.zeros(24000, dtype=np.float32)
193
+
194
+ print(f" Decoding {n_frames} frames ({len(layer0_tokens)} L0, {len(layer1_tokens)} L1, {len(layer2_tokens)} L2)")
195
+
196
+ # Truncate to complete frames
197
+ layer0_tokens = layer0_tokens[:n_frames]
198
+ layer1_tokens = layer1_tokens[:n_frames * 2]
199
+ layer2_tokens = layer2_tokens[:n_frames * 4]
200
 
201
  codes = [
202
+ torch.tensor([layer0_tokens], dtype=torch.long, device=device),
203
+ torch.tensor([layer1_tokens], dtype=torch.long, device=device),
204
+ torch.tensor([layer2_tokens], dtype=torch.long, device=device)
205
  ]
206
 
207
  # Decode
 
216
  whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer,
217
  device: str,
218
  max_new_tokens: int = 500,
219
+ temperature: float = 0.7,
220
+ debug: bool = True,
221
+ use_prompt: bool = False
222
  ):
223
  """Generate speech response from audio input."""
224
 
225
  # Encode input audio
226
  print("Encoding input audio...")
227
  audio_embeddings = encode_audio(audio_input, whisper_model, feature_extractor, adapter, device)
228
+ print(f" Audio embeddings shape: {audio_embeddings.shape}")
229
 
230
  # Generate with LLM
231
  print("Generating response...")
232
 
 
233
  with torch.no_grad():
234
  # Get embeddings layer
235
  embed_layer = llm.get_input_embeddings()
236
 
237
+ if use_prompt:
238
+ # Option A: Add BOS token after audio
239
+ bos_id = tokenizer.bos_token_id or 128000
240
+ prompt_tokens = torch.tensor([[bos_id]], dtype=torch.long, device=device)
241
+ prompt_embeds = embed_layer(prompt_tokens)
242
+ input_embeds = torch.cat([audio_embeddings.to(torch.bfloat16), prompt_embeds.to(torch.bfloat16)], dim=1)
243
+ else:
244
+ # Option B: Just audio embeddings (as trained)
245
+ # During training, target directly follows audio embeddings
246
+ input_embeds = audio_embeddings.to(torch.bfloat16)
247
+
248
+ print(f" Input embeds shape: {input_embeds.shape}")
249
+
250
+ # Create attention mask
251
+ attention_mask = torch.ones(input_embeds.shape[:2], dtype=torch.long, device=device)
252
+
253
+ # Different generation strategies
254
+ # Strategy 1: Pure greedy (most stable)
255
+ if temperature <= 0.01:
256
+ outputs = llm.generate(
257
+ inputs_embeds=input_embeds,
258
+ attention_mask=attention_mask,
259
+ max_new_tokens=max_new_tokens,
260
+ do_sample=False,
261
+ pad_token_id=tokenizer.eos_token_id,
262
+ eos_token_id=EOS_TOKEN,
263
+ use_cache=True
264
+ )
265
+ # Strategy 2: Sampling with nucleus
266
+ else:
267
+ outputs = llm.generate(
268
+ inputs_embeds=input_embeds,
269
+ attention_mask=attention_mask,
270
+ max_new_tokens=max_new_tokens,
271
+ temperature=temperature,
272
+ do_sample=True,
273
+ top_p=0.9,
274
+ top_k=50,
275
+ repetition_penalty=1.1,
276
+ pad_token_id=tokenizer.eos_token_id,
277
+ eos_token_id=EOS_TOKEN,
278
+ use_cache=True
279
+ )
280
+
281
+ # Extract generated tokens
282
  generated_tokens = outputs[0].tolist()
 
283
 
284
+ if debug:
285
+ print(f"\n=== DEBUG: Generated {len(generated_tokens)} total tokens ===")
286
+ # Show first 50 tokens
287
+ print(f"First 50 tokens: {generated_tokens[:50]}")
288
+ # Show last 20 tokens
289
+ print(f"Last 20 tokens: {generated_tokens[-20:]}")
290
+
291
+ # Show token ranges with correct SNAC range
292
+ text_tokens = [t for t in generated_tokens if t < SNAC_BASE]
293
+ snac_range_tokens = [t for t in generated_tokens if SNAC_BASE <= t < SNAC_MAX]
294
+ other_high = [t for t in generated_tokens if t >= SNAC_MAX]
295
+
296
+ print(f"\nToken distribution:")
297
+ print(f" Text tokens (<{SNAC_BASE}): {len(text_tokens)}")
298
+ print(f" SNAC tokens ({SNAC_BASE}-{SNAC_MAX}): {len(snac_range_tokens)}")
299
+ print(f" Other high tokens (>={SNAC_MAX}): {len(other_high)}")
300
+
301
+ # Try to decode text tokens
302
+ if text_tokens:
303
+ try:
304
+ decoded_text = tokenizer.decode(text_tokens, skip_special_tokens=False)
305
+ print(f"\nDecoded text: {decoded_text[:500]}")
306
+ except Exception as e:
307
+ print(f"Could not decode text: {e}")
308
+
309
+ # Show some SNAC tokens if any
310
+ if snac_range_tokens:
311
+ print(f"\nFirst 20 SNAC tokens: {snac_range_tokens[:20]}")
312
+
313
+ # Extract SNAC tokens from output (correct range)
314
+ snac_tokens = [t for t in generated_tokens if SNAC_BASE <= t < SNAC_MAX]
315
+
316
+ print(f"\nGenerated {len(snac_tokens)} SNAC tokens")
317
 
318
  # Decode to audio
319
  print("Decoding to audio...")
 
327
  parser.add_argument("--checkpoint", type=str, required=True, help="Path to model checkpoint")
328
  parser.add_argument("--input", type=str, required=True, help="Input audio file")
329
  parser.add_argument("--output", type=str, default="output.wav", help="Output audio file")
330
+ parser.add_argument("--max_tokens", type=int, default=1000, help="Max tokens to generate")
331
+ parser.add_argument("--temperature", type=float, default=0.7, help="Sampling temperature (0 for greedy)")
332
+ parser.add_argument("--use_prompt", action="store_true", help="Add BOS token after audio embeddings")
333
+ parser.add_argument("--no_debug", action="store_true", help="Disable debug output")
334
  args = parser.parse_args()
335
 
336
  device = "cuda" if torch.cuda.is_available() else "cpu"
 
347
  whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer,
348
  device,
349
  max_new_tokens=args.max_tokens,
350
+ temperature=args.temperature,
351
+ debug=not args.no_debug,
352
+ use_prompt=args.use_prompt
353
  )
354
 
355
  # Save output
training/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training module for Speech-to-Speech finetuning.
3
+
4
+ Follows SOLID principles:
5
+ - Single Responsibility: Each module handles one concern
6
+ - Open/Closed: Base classes allow extension without modification
7
+ - Dependency Inversion: Components depend on abstractions
8
+ """
9
+
10
+ from .config import TrainingConfig, GPUConfig
11
+ from .data import load_sharded_dataset, InterleavedDataset, create_dataloader
12
+ from .models import SpeechAdapter
13
+ from .interleaving import create_interleaved_sequence, get_text_ratio
14
+ from .checkpoint import CheckpointManager
15
+ from .utils import setup_logging, log, get_device_info
16
+
17
+ __all__ = [
18
+ 'TrainingConfig',
19
+ 'GPUConfig',
20
+ 'load_sharded_dataset',
21
+ 'InterleavedDataset',
22
+ 'create_dataloader',
23
+ 'SpeechAdapter',
24
+ 'create_interleaved_sequence',
25
+ 'get_text_ratio',
26
+ 'CheckpointManager',
27
+ 'setup_logging',
28
+ 'log',
29
+ 'get_device_info',
30
+ ]
training/checkpoint.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Checkpoint management for training.
3
+
4
+ Single Responsibility: Only handles saving and loading checkpoints.
5
+ """
6
+
7
+ import os
8
+ import torch
9
+ import threading
10
+ from typing import Dict, Any, Optional, List
11
+ from pathlib import Path
12
+ from dataclasses import dataclass
13
+ from .utils import log
14
+
15
+
16
+ @dataclass
17
+ class TrainingState:
18
+ """Immutable training state for checkpointing."""
19
+ step: int
20
+ epoch: int
21
+ loss: float
22
+ text_ratio: float
23
+ best_loss: float = float("inf")
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ return {
27
+ "step": self.step,
28
+ "epoch": self.epoch,
29
+ "loss": self.loss,
30
+ "text_ratio": self.text_ratio,
31
+ "best_loss": self.best_loss,
32
+ }
33
+
34
+ @classmethod
35
+ def from_dict(cls, d: Dict[str, Any]) -> 'TrainingState':
36
+ return cls(
37
+ step=d.get("step", 0),
38
+ epoch=d.get("epoch", 0),
39
+ loss=d.get("loss", 0.0),
40
+ text_ratio=d.get("text_ratio", 0.9),
41
+ best_loss=d.get("best_loss", float("inf")),
42
+ )
43
+
44
+
45
+ class CheckpointManager:
46
+ """
47
+ Manages checkpoint saving and loading with async support.
48
+
49
+ Single Responsibility: Only handles checkpoint I/O.
50
+ Open/Closed: Can extend with new checkpoint formats without modification.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ output_dir: str,
56
+ prefix: str = "checkpoint",
57
+ verbose: bool = True,
58
+ max_checkpoints: Optional[int] = None
59
+ ):
60
+ """
61
+ Initialize checkpoint manager.
62
+
63
+ Args:
64
+ output_dir: Directory for saving checkpoints
65
+ prefix: Prefix for checkpoint filenames
66
+ verbose: Whether to log operations
67
+ max_checkpoints: Maximum checkpoints to keep (None = keep all)
68
+ """
69
+ self.output_dir = Path(output_dir)
70
+ self.prefix = prefix
71
+ self.verbose = verbose
72
+ self.max_checkpoints = max_checkpoints
73
+ self._save_threads: List[threading.Thread] = []
74
+
75
+ # Create output directory
76
+ self.output_dir.mkdir(parents=True, exist_ok=True)
77
+
78
+ def save(
79
+ self,
80
+ state_dict: Dict[str, Any],
81
+ filename: str,
82
+ async_save: bool = True
83
+ ) -> str:
84
+ """
85
+ Save checkpoint.
86
+
87
+ Args:
88
+ state_dict: State dictionary to save
89
+ filename: Checkpoint filename
90
+ async_save: Whether to save asynchronously
91
+
92
+ Returns:
93
+ Path to saved checkpoint
94
+ """
95
+ path = self.output_dir / filename
96
+
97
+ if async_save:
98
+ self._save_async(state_dict, path)
99
+ else:
100
+ self._save_sync(state_dict, path)
101
+
102
+ return str(path)
103
+
104
+ def save_step(
105
+ self,
106
+ adapter_state: Dict[str, Any],
107
+ optimizer_state: Dict[str, Any],
108
+ training_state: TrainingState,
109
+ async_save: bool = True
110
+ ) -> str:
111
+ """Save step checkpoint."""
112
+ state_dict = {
113
+ "adapter": adapter_state,
114
+ "optimizer": optimizer_state,
115
+ **training_state.to_dict()
116
+ }
117
+ filename = f"{self.prefix}_step{training_state.step}.pt"
118
+ return self.save(state_dict, filename, async_save)
119
+
120
+ def save_epoch(
121
+ self,
122
+ adapter_state: Dict[str, Any],
123
+ optimizer_state: Dict[str, Any],
124
+ training_state: TrainingState,
125
+ async_save: bool = True
126
+ ) -> str:
127
+ """Save epoch checkpoint."""
128
+ state_dict = {
129
+ "adapter": adapter_state,
130
+ "optimizer": optimizer_state,
131
+ **training_state.to_dict()
132
+ }
133
+ filename = f"{self.prefix}_epoch{training_state.epoch}.pt"
134
+ return self.save(state_dict, filename, async_save)
135
+
136
+ def save_best(
137
+ self,
138
+ adapter_state: Dict[str, Any],
139
+ training_state: TrainingState,
140
+ lora_state: Optional[Dict[str, Any]] = None,
141
+ async_save: bool = True
142
+ ) -> str:
143
+ """Save best model checkpoint."""
144
+ state_dict = {
145
+ "adapter": adapter_state,
146
+ **training_state.to_dict()
147
+ }
148
+ if lora_state is not None:
149
+ state_dict["lora"] = lora_state
150
+
151
+ filename = f"{self.prefix}_best.pt"
152
+ return self.save(state_dict, filename, async_save)
153
+
154
+ def load(self, path: str) -> Dict[str, Any]:
155
+ """
156
+ Load checkpoint.
157
+
158
+ Args:
159
+ path: Path to checkpoint
160
+
161
+ Returns:
162
+ Loaded state dictionary
163
+ """
164
+ if self.verbose:
165
+ log(f"Loading checkpoint: {path}")
166
+ return torch.load(path, map_location="cpu", weights_only=False)
167
+
168
+ def load_latest(self) -> Optional[Dict[str, Any]]:
169
+ """Load the most recent checkpoint."""
170
+ checkpoints = self._get_checkpoints()
171
+ if not checkpoints:
172
+ return None
173
+ return self.load(str(checkpoints[-1]))
174
+
175
+ def wait_for_saves(self):
176
+ """Wait for all async saves to complete."""
177
+ for t in self._save_threads:
178
+ t.join()
179
+ self._save_threads = []
180
+
181
+ def _save_sync(self, state_dict: Dict[str, Any], path: Path):
182
+ """Synchronous save."""
183
+ # Copy tensors to CPU
184
+ state_copy = self._copy_to_cpu(state_dict)
185
+ torch.save(state_copy, path)
186
+ if self.verbose:
187
+ log(f"[Checkpoint] Saved: {path.name}")
188
+
189
+ def _save_async(self, state_dict: Dict[str, Any], path: Path):
190
+ """Asynchronous save."""
191
+ # Clean up completed threads
192
+ self._save_threads = [t for t in self._save_threads if t.is_alive()]
193
+
194
+ # Copy tensors to CPU
195
+ state_copy = self._copy_to_cpu(state_dict)
196
+
197
+ def _save():
198
+ try:
199
+ torch.save(state_copy, path)
200
+ if self.verbose:
201
+ log(f"[Checkpoint] Saved: {path.name}")
202
+ except Exception as e:
203
+ if self.verbose:
204
+ log(f"[Checkpoint] Error saving {path.name}: {e}")
205
+
206
+ thread = threading.Thread(target=_save, daemon=True)
207
+ thread.start()
208
+ self._save_threads.append(thread)
209
+
210
+ # Cleanup old checkpoints if needed
211
+ if self.max_checkpoints:
212
+ self._cleanup_old_checkpoints()
213
+
214
+ def _copy_to_cpu(self, obj: Any) -> Any:
215
+ """Recursively copy tensors to CPU."""
216
+ if isinstance(obj, torch.Tensor):
217
+ return obj.detach().cpu().clone()
218
+ elif isinstance(obj, dict):
219
+ return {k: self._copy_to_cpu(v) for k, v in obj.items()}
220
+ elif isinstance(obj, list):
221
+ return [self._copy_to_cpu(v) for v in obj]
222
+ return obj
223
+
224
+ def _get_checkpoints(self) -> List[Path]:
225
+ """Get sorted list of checkpoint files."""
226
+ pattern = f"{self.prefix}_step*.pt"
227
+ checkpoints = sorted(
228
+ self.output_dir.glob(pattern),
229
+ key=lambda p: int(p.stem.split("step")[-1])
230
+ )
231
+ return checkpoints
232
+
233
+ def _cleanup_old_checkpoints(self):
234
+ """Remove old checkpoints beyond max_checkpoints."""
235
+ if not self.max_checkpoints:
236
+ return
237
+
238
+ checkpoints = self._get_checkpoints()
239
+ while len(checkpoints) > self.max_checkpoints:
240
+ oldest = checkpoints.pop(0)
241
+ try:
242
+ oldest.unlink()
243
+ if self.verbose:
244
+ log(f"[Checkpoint] Removed old: {oldest.name}")
245
+ except Exception:
246
+ pass
247
+
248
+
249
+ class Stage1CheckpointManager(CheckpointManager):
250
+ """Checkpoint manager for Stage 1 training."""
251
+
252
+ def __init__(self, output_dir: str, **kwargs):
253
+ super().__init__(output_dir, prefix="stage1", **kwargs)
254
+
255
+
256
+ class Stage2CheckpointManager(CheckpointManager):
257
+ """Checkpoint manager for Stage 2 training."""
258
+
259
+ def __init__(self, output_dir: str, **kwargs):
260
+ super().__init__(output_dir, prefix="stage2", **kwargs)
261
+
262
+ def save_best(
263
+ self,
264
+ adapter_state: Dict[str, Any],
265
+ training_state: TrainingState,
266
+ lora_state: Dict[str, Any],
267
+ async_save: bool = True
268
+ ) -> str:
269
+ """Save best model with LoRA weights."""
270
+ return super().save_best(
271
+ adapter_state, training_state,
272
+ lora_state=lora_state, async_save=async_save
273
+ )
training/config.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration management using dataclasses.
3
+
4
+ Single Responsibility: Only handles configuration.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional, List
9
+ import os
10
+ import torch
11
+
12
+
13
+ # ============================================================
14
+ # Constants (Single source of truth)
15
+ # ============================================================
16
+ SNAC_BASE_OFFSET = 128266
17
+ SNAC_LAYERS_PER_FRAME = 7
18
+ SNAC_LAYER_OFFSET = 4096
19
+ EOS_TOKEN = 128009
20
+
21
+ # Model defaults
22
+ DEFAULT_WHISPER_DIM = 1280
23
+ DEFAULT_LLM_DIM = 3072
24
+ DEFAULT_DOWNSAMPLE = 5
25
+ DEFAULT_INTERMEDIATE_DIM = 2048
26
+ DEFAULT_MODEL_PATH = "canopylabs/3b-es_it-ft-research_release"
27
+
28
+ # LoRA defaults
29
+ DEFAULT_LORA_R = 16
30
+ DEFAULT_LORA_ALPHA = 32
31
+ DEFAULT_LORA_DROPOUT = 0.05
32
+ DEFAULT_LORA_MODULES = [
33
+ "q_proj", "k_proj", "v_proj", "o_proj",
34
+ "gate_proj", "up_proj", "down_proj"
35
+ ]
36
+
37
+
38
+ @dataclass
39
+ class GPUConfig:
40
+ """GPU configuration detected at runtime."""
41
+ name: str = "Unknown"
42
+ vram_gb: int = 0
43
+ batch_size: int = 2
44
+ grad_accum: int = 16
45
+ dtype: torch.dtype = torch.float32
46
+ device_type: str = "cpu"
47
+
48
+ @classmethod
49
+ def auto_detect(cls) -> 'GPUConfig':
50
+ """Detect GPU and return optimal configuration."""
51
+ config = cls()
52
+
53
+ # Try CUDA (NVIDIA)
54
+ if torch.cuda.is_available():
55
+ try:
56
+ props = torch.cuda.get_device_properties(0)
57
+ config.vram_gb = props.total_memory // (1024**3)
58
+ config.name = props.name
59
+ config.device_type = "cuda"
60
+ config.dtype = torch.bfloat16
61
+ except Exception:
62
+ pass
63
+
64
+ # Try MPS (Apple Silicon)
65
+ elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
66
+ config.name = "Apple Silicon (MPS)"
67
+ config.device_type = "mps"
68
+ config.dtype = torch.float32
69
+ try:
70
+ import subprocess
71
+ result = subprocess.run(
72
+ ['sysctl', '-n', 'hw.memsize'],
73
+ capture_output=True, text=True
74
+ )
75
+ total_mem = int(result.stdout.strip()) // (1024**3)
76
+ config.vram_gb = total_mem // 2
77
+ except Exception:
78
+ config.vram_gb = 8
79
+
80
+ # Try ROCm (AMD)
81
+ elif hasattr(torch, 'hip') or os.environ.get('ROCM_HOME'):
82
+ try:
83
+ if torch.cuda.is_available():
84
+ props = torch.cuda.get_device_properties(0)
85
+ config.vram_gb = props.total_memory // (1024**3)
86
+ config.name = f"AMD {props.name}"
87
+ config.device_type = "cuda"
88
+ config.dtype = torch.bfloat16
89
+ except Exception:
90
+ config.name = "AMD ROCm"
91
+ config.vram_gb = 16
92
+
93
+ # Fallback: nvidia-smi
94
+ if config.vram_gb == 0:
95
+ try:
96
+ import subprocess
97
+ result = subprocess.run(
98
+ ['nvidia-smi', '--query-gpu=name,memory.total',
99
+ '--format=csv,noheader,nounits'],
100
+ capture_output=True, text=True
101
+ )
102
+ lines = result.stdout.strip().split('\n')
103
+ config.name, vram_mb = lines[0].split(', ')
104
+ config.vram_gb = int(vram_mb) // 1024
105
+ config.device_type = "cuda"
106
+ config.dtype = torch.bfloat16
107
+ except Exception:
108
+ pass
109
+
110
+ # Set batch size based on VRAM
111
+ config.batch_size, config.grad_accum = cls._get_batch_config(config.vram_gb)
112
+ return config
113
+
114
+ @staticmethod
115
+ def _get_batch_config(vram_gb: int) -> tuple:
116
+ """Get optimal batch size and gradient accumulation based on VRAM."""
117
+ if vram_gb >= 140: # H200 (141GB)
118
+ return 12, 3 # H200: batch=12, effective=36
119
+ elif vram_gb >= 80:
120
+ return 6, 5
121
+ elif vram_gb >= 35:
122
+ return 4, 8
123
+ elif vram_gb >= 16:
124
+ return 2, 16
125
+ else:
126
+ return 1, 32
127
+
128
+
129
+ @dataclass
130
+ class TrainingConfig:
131
+ """Training configuration with sensible defaults."""
132
+ # Data
133
+ data_paths: List[str] = field(default_factory=list)
134
+ output_dir: str = "./checkpoints"
135
+
136
+ # Training hyperparameters
137
+ learning_rate: float = 5e-5
138
+ epochs: int = 2
139
+ batch_size: Optional[int] = None
140
+ grad_accum: Optional[int] = None
141
+ warmup_ratio: float = 0.03
142
+ max_grad_norm: float = 1.0
143
+ label_smoothing: float = 0.1
144
+ weight_decay: float = 0.01
145
+
146
+ # Sequence limits
147
+ max_audio_len: int = 500
148
+ max_seq_len: int = 2048
149
+
150
+ # Scheduled interleaving (IST-LM)
151
+ initial_text_ratio: float = 0.9
152
+ decay_steps: int = 300
153
+ dynamic_decay: bool = False
154
+ no_decay: bool = False # Stage 1: keep text_ratio fixed
155
+ final_audio_portion: float = 0.2
156
+
157
+ # Model
158
+ model_path: str = DEFAULT_MODEL_PATH
159
+
160
+ # Checkpointing
161
+ save_steps: int = 200
162
+ resume_from: Optional[str] = None
163
+
164
+ # Memory
165
+ vram_fraction: float = 0.80
166
+ ram_limit_gb: Optional[float] = None
167
+ gradient_checkpointing: bool = False
168
+
169
+ # Mode flags
170
+ demo_mode: bool = False
171
+ test_mode: bool = False
172
+
173
+ def __post_init__(self):
174
+ """Apply GPU auto-detection if batch_size not set."""
175
+ if self.batch_size is None or self.grad_accum is None:
176
+ gpu_config = GPUConfig.auto_detect()
177
+ if self.batch_size is None:
178
+ self.batch_size = gpu_config.batch_size
179
+ if self.grad_accum is None:
180
+ self.grad_accum = gpu_config.grad_accum
181
+
182
+ @classmethod
183
+ def from_args(cls, args) -> 'TrainingConfig':
184
+ """Create config from argparse namespace."""
185
+ return cls(
186
+ data_paths=[p.strip() for p in args.data.split(",")],
187
+ output_dir=args.output_dir,
188
+ learning_rate=args.lr,
189
+ epochs=args.epochs,
190
+ batch_size=args.batch_size,
191
+ grad_accum=args.grad_accum,
192
+ warmup_ratio=args.warmup_ratio,
193
+ max_grad_norm=args.max_grad_norm,
194
+ label_smoothing=args.label_smoothing,
195
+ max_seq_len=args.max_seq_len,
196
+ initial_text_ratio=args.initial_text_ratio,
197
+ decay_steps=args.decay_steps,
198
+ dynamic_decay=getattr(args, 'dynamic_decay', False),
199
+ no_decay=getattr(args, 'no_decay', False),
200
+ final_audio_portion=getattr(args, 'final_audio_portion', 0.2),
201
+ model_path=args.model_path,
202
+ save_steps=args.save_steps,
203
+ resume_from=args.resume,
204
+ vram_fraction=args.vram_fraction,
205
+ ram_limit_gb=args.ram_limit_gb,
206
+ gradient_checkpointing=args.gradient_checkpointing,
207
+ demo_mode=args.demo,
208
+ test_mode=args.test,
209
+ )
210
+
211
+
212
+ @dataclass
213
+ class LoRAConfig:
214
+ """LoRA configuration for Stage 2."""
215
+ r: int = DEFAULT_LORA_R
216
+ alpha: int = DEFAULT_LORA_ALPHA
217
+ dropout: float = DEFAULT_LORA_DROPOUT
218
+ target_modules: List[str] = field(default_factory=lambda: DEFAULT_LORA_MODULES.copy())
219
+ bias: str = "none"
220
+
221
+ def to_peft_config(self):
222
+ """Convert to PEFT LoraConfig."""
223
+ from peft import LoraConfig as PeftLoraConfig, TaskType
224
+ return PeftLoraConfig(
225
+ r=self.r,
226
+ lora_alpha=self.alpha,
227
+ lora_dropout=self.dropout,
228
+ target_modules=self.target_modules,
229
+ bias=self.bias,
230
+ task_type=TaskType.CAUSAL_LM,
231
+ )
training/data.py ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data loading and dataset management.
3
+
4
+ Single Responsibility: Only handles data loading and dataset creation.
5
+ Open/Closed: Can extend with new dataset formats without modifying existing code.
6
+
7
+ Optimizations:
8
+ - Lazy loading for memory efficiency
9
+ - Sequence length bucketing for reduced padding overhead
10
+ - LRU cache for batch files
11
+ """
12
+
13
+ import gc
14
+ import random
15
+ import torch
16
+ import torch.nn.functional as F
17
+ from torch.utils.data import Dataset, DataLoader, ConcatDataset, Sampler
18
+ from pathlib import Path
19
+ from typing import List, Dict, Any, Optional, Callable, Tuple, Iterator
20
+ from .utils import log
21
+
22
+
23
+ # ============================================================
24
+ # Sequence Length Bucketing (Reduces padding overhead)
25
+ # ============================================================
26
+ class BucketBatchSampler(Sampler[List[int]]):
27
+ """
28
+ Batch sampler that groups samples by sequence length into buckets.
29
+
30
+ This reduces padding overhead by batching similar-length sequences together.
31
+ Based on TensorFlow's bucket_by_sequence_length concept.
32
+
33
+ Benefits:
34
+ - Reduces wasted computation on padding tokens
35
+ - More consistent memory usage per batch
36
+ - Can improve training speed by 10-30%
37
+
38
+ Args:
39
+ lengths: List of sequence lengths for each sample
40
+ batch_size: Number of samples per batch
41
+ bucket_boundaries: Length boundaries for buckets (auto-computed if None)
42
+ shuffle: Whether to shuffle within buckets
43
+ drop_last: Whether to drop incomplete batches
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ lengths: List[int],
49
+ batch_size: int,
50
+ bucket_boundaries: Optional[List[int]] = None,
51
+ shuffle: bool = True,
52
+ drop_last: bool = False,
53
+ ):
54
+ self.lengths = lengths
55
+ self.batch_size = batch_size
56
+ self.shuffle = shuffle
57
+ self.drop_last = drop_last
58
+
59
+ # Auto-compute bucket boundaries if not provided
60
+ if bucket_boundaries is None:
61
+ # Create ~10 buckets based on length distribution
62
+ sorted_lens = sorted(lengths)
63
+ n = len(sorted_lens)
64
+ bucket_boundaries = [
65
+ sorted_lens[int(n * p)] for p in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
66
+ ]
67
+ # Remove duplicates and sort
68
+ bucket_boundaries = sorted(set(bucket_boundaries))
69
+
70
+ self.bucket_boundaries = bucket_boundaries
71
+
72
+ # Assign samples to buckets
73
+ self.buckets: Dict[int, List[int]] = {i: [] for i in range(len(bucket_boundaries) + 1)}
74
+ for idx, length in enumerate(lengths):
75
+ bucket_id = self._get_bucket_id(length)
76
+ self.buckets[bucket_id].append(idx)
77
+
78
+ def _get_bucket_id(self, length: int) -> int:
79
+ """Find which bucket a length belongs to."""
80
+ for i, boundary in enumerate(self.bucket_boundaries):
81
+ if length <= boundary:
82
+ return i
83
+ return len(self.bucket_boundaries)
84
+
85
+ def __iter__(self) -> Iterator[List[int]]:
86
+ """Generate batches from buckets."""
87
+ # Collect all batches from all buckets
88
+ all_batches = []
89
+
90
+ for bucket_id, indices in self.buckets.items():
91
+ if not indices:
92
+ continue
93
+
94
+ # Shuffle within bucket
95
+ bucket_indices = indices.copy()
96
+ if self.shuffle:
97
+ random.shuffle(bucket_indices)
98
+
99
+ # Create batches
100
+ for i in range(0, len(bucket_indices), self.batch_size):
101
+ batch = bucket_indices[i:i + self.batch_size]
102
+ if len(batch) == self.batch_size or not self.drop_last:
103
+ all_batches.append(batch)
104
+
105
+ # Shuffle batches across buckets
106
+ if self.shuffle:
107
+ random.shuffle(all_batches)
108
+
109
+ yield from all_batches
110
+
111
+ def __len__(self) -> int:
112
+ """Return total number of batches."""
113
+ total = 0
114
+ for indices in self.buckets.values():
115
+ n_batches = len(indices) // self.batch_size
116
+ if not self.drop_last and len(indices) % self.batch_size != 0:
117
+ n_batches += 1
118
+ total += n_batches
119
+ return total
120
+
121
+
122
+ # ============================================================
123
+ # Lazy Sharded Dataset (Memory Efficient)
124
+ # ============================================================
125
+ class LazyShardedDataset(Dataset):
126
+ """
127
+ Memory-efficient dataset that loads batch files on-demand.
128
+
129
+ Instead of loading all data into memory, maintains an index of
130
+ which sample is in which batch file, and loads batches as needed.
131
+
132
+ Also stores approximate sequence lengths for bucketing optimization.
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ batch_files: List[Path],
138
+ tokenizer,
139
+ max_audio_len: int = 500,
140
+ max_seq_len: int = 2048,
141
+ cache_size: int = 3, # Number of batches to keep in memory
142
+ verbose: bool = True
143
+ ):
144
+ self.batch_files = batch_files
145
+ self.tokenizer = tokenizer
146
+ self.max_audio = max_audio_len * 5
147
+ self.max_seq_len = max_seq_len
148
+ self.cache_size = cache_size
149
+ self.verbose = verbose
150
+
151
+ # Build index: sample_idx -> (batch_idx, local_idx)
152
+ self.index: List[Tuple[int, int]] = []
153
+ self.batch_sizes: List[int] = []
154
+ # Store sequence lengths for bucketing
155
+ self.sequence_lengths: List[int] = []
156
+
157
+ if verbose:
158
+ log(f" Indexing {len(batch_files)} batch files...")
159
+
160
+ for batch_idx, bf in enumerate(batch_files):
161
+ # Quick load to get size and lengths
162
+ data = torch.load(bf, map_location="cpu", weights_only=False)
163
+ batch_size = len(data)
164
+ self.batch_sizes.append(batch_size)
165
+
166
+ for local_idx in range(batch_size):
167
+ self.index.append((batch_idx, local_idx))
168
+ # Estimate sequence length (SNAC tokens dominate)
169
+ item = data[local_idx]
170
+ snac_len = len(item.get("snac_tokens", [])) // 7 * 7
171
+ self.sequence_lengths.append(snac_len)
172
+
173
+ del data
174
+ if (batch_idx + 1) % 100 == 0:
175
+ gc.collect()
176
+ if verbose:
177
+ log(f" Indexed {batch_idx+1}/{len(batch_files)} batches ({len(self.index):,} samples)")
178
+
179
+ if verbose:
180
+ log(f" Total indexed: {len(self.index):,} samples")
181
+
182
+ # LRU cache for loaded batches
183
+ self._cache: Dict[int, List[Dict]] = {}
184
+ self._cache_order: List[int] = []
185
+
186
+ def get_sequence_lengths(self) -> List[int]:
187
+ """Return sequence lengths for bucketing."""
188
+ return self.sequence_lengths
189
+
190
+ def __len__(self) -> int:
191
+ return len(self.index)
192
+
193
+ def _load_batch(self, batch_idx: int) -> List[Dict]:
194
+ """Load a batch file, using cache."""
195
+ if batch_idx in self._cache:
196
+ # Move to end of cache order (most recently used)
197
+ self._cache_order.remove(batch_idx)
198
+ self._cache_order.append(batch_idx)
199
+ return self._cache[batch_idx]
200
+
201
+ # Load from disk
202
+ data = torch.load(self.batch_files[batch_idx], map_location="cpu", weights_only=False)
203
+
204
+ # Add to cache
205
+ self._cache[batch_idx] = data
206
+ self._cache_order.append(batch_idx)
207
+
208
+ # Evict old batches if cache is full
209
+ while len(self._cache_order) > self.cache_size:
210
+ old_idx = self._cache_order.pop(0)
211
+ del self._cache[old_idx]
212
+ gc.collect()
213
+
214
+ return data
215
+
216
+ def __getitem__(self, idx: int) -> Dict[str, Any]:
217
+ batch_idx, local_idx = self.index[idx]
218
+ batch_data = self._load_batch(batch_idx)
219
+ item = batch_data[local_idx]
220
+
221
+ # Process item (same as InterleavedDataset)
222
+ whisper = item["whisper_features"][:self.max_audio]
223
+ text_tokens = self._get_text_tokens(item)
224
+ snac_list = self._get_snac_tokens(item)
225
+ word_alignments = item.get("word_alignments", None)
226
+ answer_text = item.get("answer", "")
227
+
228
+ return {
229
+ "whisper": whisper,
230
+ "text_tokens": text_tokens,
231
+ "snac_tokens": snac_list,
232
+ "word_alignments": word_alignments,
233
+ "answer_text": answer_text
234
+ }
235
+
236
+ def _get_text_tokens(self, item: Dict[str, Any]) -> List[int]:
237
+ if "text_tokens" in item and len(item["text_tokens"]) > 0:
238
+ tt = item["text_tokens"]
239
+ return tt.tolist() if hasattr(tt, 'tolist') else list(tt)
240
+ text = item.get("answer", item.get("text", ""))
241
+ if isinstance(text, str) and len(text) > 0:
242
+ return self.tokenizer.encode(text, add_special_tokens=False)
243
+ return []
244
+
245
+ def _get_snac_tokens(self, item: Dict[str, Any]) -> List[int]:
246
+ snac = item["snac_tokens"]
247
+ snac_len = (len(snac) // 7) * 7
248
+ snac = snac[:snac_len] if snac_len > 0 else snac[:7]
249
+ return snac.tolist() if hasattr(snac, 'tolist') else list(snac)
250
+
251
+
252
+ # ============================================================
253
+ # Sharded Dataset Loading (Industry Standard)
254
+ # ============================================================
255
+ class ShardedDatasetLoader:
256
+ """
257
+ Load datasets from single files or sharded batch directories.
258
+
259
+ Supports:
260
+ - Single .pt file
261
+ - Batch directory with batch_*.pt files
262
+ - Mixed (base file + batch files)
263
+
264
+ Dependency Inversion: Uses abstract path interface, not concrete file operations.
265
+ """
266
+
267
+ def __init__(self, verbose: bool = True):
268
+ self.verbose = verbose
269
+
270
+ def load(self, path: str) -> List[Dict[str, Any]]:
271
+ """Load dataset from path (file or directory)."""
272
+ path = Path(path)
273
+ samples = []
274
+
275
+ # Case 1: Explicit batches directory
276
+ if path.name.endswith('.batches') and path.is_dir():
277
+ samples = self._load_batches_dir(path)
278
+
279
+ # Case 2: Single file (possibly with batches)
280
+ elif path.exists() and path.is_file():
281
+ samples = self._load_file_with_batches(path)
282
+
283
+ # Case 3: Only batches directory exists
284
+ else:
285
+ batches_dir = Path(f"{path}.batches")
286
+ if batches_dir.exists() and batches_dir.is_dir():
287
+ samples = self._load_batches_dir(batches_dir)
288
+ else:
289
+ raise FileNotFoundError(f"No dataset found at {path}")
290
+
291
+ return samples
292
+
293
+ def _load_batches_dir(self, batches_dir: Path) -> List[Dict[str, Any]]:
294
+ """Load all batch files from a directory."""
295
+ batch_files = sorted(batches_dir.glob("batch_*.pt"))
296
+ if not batch_files:
297
+ raise FileNotFoundError(f"No batch files in {batches_dir}")
298
+
299
+ if self.verbose:
300
+ log(f" Loading {len(batch_files)} batch files from {batches_dir.name}/")
301
+
302
+ samples = []
303
+ for i, bf in enumerate(batch_files):
304
+ items = torch.load(bf, map_location="cpu", weights_only=False)
305
+ samples.extend(items)
306
+ del items
307
+ if (i + 1) % 100 == 0:
308
+ gc.collect()
309
+ if self.verbose:
310
+ log(f" Loaded {i+1}/{len(batch_files)} batches ({len(samples):,} samples)")
311
+
312
+ return samples
313
+
314
+ def _load_file_with_batches(self, path: Path) -> List[Dict[str, Any]]:
315
+ """Load base file and any associated batch files."""
316
+ # Load base file
317
+ base = torch.load(path, map_location="cpu", weights_only=False, mmap=True)
318
+ samples = list(base)
319
+ del base
320
+ gc.collect()
321
+
322
+ if self.verbose:
323
+ log(f" Base file: {len(samples):,} samples")
324
+
325
+ # Check for batch files
326
+ batches_dir = Path(f"{path}.batches")
327
+ if batches_dir.exists() and batches_dir.is_dir():
328
+ batch_files = sorted(batches_dir.glob("batch_*.pt"))
329
+ if batch_files:
330
+ if self.verbose:
331
+ log(f" Found {len(batch_files)} batch files")
332
+ for i, bf in enumerate(batch_files):
333
+ items = torch.load(bf, map_location="cpu", weights_only=False)
334
+ samples.extend(items)
335
+ del items
336
+ if (i + 1) % 100 == 0:
337
+ gc.collect()
338
+ if self.verbose:
339
+ log(f" Loaded {i+1}/{len(batch_files)} batches ({len(samples):,} total)")
340
+
341
+ return samples
342
+
343
+
344
+ def load_sharded_dataset(path: str, verbose: bool = True) -> List[Dict[str, Any]]:
345
+ """Convenience function for loading sharded datasets."""
346
+ loader = ShardedDatasetLoader(verbose=verbose)
347
+ return loader.load(path)
348
+
349
+
350
+ # ============================================================
351
+ # Dataset Classes
352
+ # ============================================================
353
+ class InterleavedDataset(Dataset):
354
+ """
355
+ Dataset that prepares samples for interleaved training.
356
+
357
+ Single Responsibility: Only handles sample access and preprocessing.
358
+ """
359
+
360
+ def __init__(
361
+ self,
362
+ data: List[Dict[str, Any]],
363
+ tokenizer,
364
+ max_audio_len: int = 500,
365
+ max_seq_len: int = 2048
366
+ ):
367
+ self.data = data
368
+ self.tokenizer = tokenizer
369
+ self.max_audio = max_audio_len * 5 # Account for downsampling
370
+ self.max_seq_len = max_seq_len
371
+
372
+ def __len__(self) -> int:
373
+ return len(self.data)
374
+
375
+ def __getitem__(self, idx: int) -> Dict[str, Any]:
376
+ item = self.data[idx]
377
+
378
+ # Whisper features (truncate if needed)
379
+ whisper = item["whisper_features"][:self.max_audio]
380
+
381
+ # Text tokens - use pre-computed if available
382
+ text_tokens = self._get_text_tokens(item)
383
+
384
+ # SNAC tokens (ensure multiple of 7)
385
+ snac_list = self._get_snac_tokens(item)
386
+
387
+ # Optional fields
388
+ word_alignments = item.get("word_alignments", None)
389
+ answer_text = item.get("answer", "")
390
+
391
+ return {
392
+ "whisper": whisper,
393
+ "text_tokens": text_tokens,
394
+ "snac_tokens": snac_list,
395
+ "word_alignments": word_alignments,
396
+ "answer_text": answer_text
397
+ }
398
+
399
+ def _get_text_tokens(self, item: Dict[str, Any]) -> List[int]:
400
+ """Extract or generate text tokens from item."""
401
+ if "text_tokens" in item and len(item["text_tokens"]) > 0:
402
+ tt = item["text_tokens"]
403
+ return tt.tolist() if hasattr(tt, 'tolist') else list(tt)
404
+
405
+ text = item.get("answer", item.get("text", ""))
406
+ if isinstance(text, str) and len(text) > 0:
407
+ return self.tokenizer.encode(text, add_special_tokens=False)
408
+
409
+ return []
410
+
411
+ def _get_snac_tokens(self, item: Dict[str, Any]) -> List[int]:
412
+ """Extract SNAC tokens, ensuring multiple of 7."""
413
+ snac = item["snac_tokens"]
414
+ snac_len = (len(snac) // 7) * 7
415
+ snac = snac[:snac_len] if snac_len > 0 else snac[:7]
416
+ return snac.tolist() if hasattr(snac, 'tolist') else list(snac)
417
+
418
+
419
+ # ============================================================
420
+ # Collate Functions
421
+ # ============================================================
422
+ def collate_simple(batch: List[Dict[str, Any]]) -> Dict[str, Any]:
423
+ """
424
+ Simple collate that pads whisper features.
425
+ Interleaving is done in training loop for correct text_ratio.
426
+ """
427
+ max_w = max(b["whisper"].shape[0] for b in batch)
428
+ max_w = ((max_w + 4) // 5) * 5 # Align to downsample factor
429
+
430
+ whisper_batch = []
431
+ raw_data = []
432
+
433
+ for b in batch:
434
+ w = b["whisper"]
435
+ w_pad = F.pad(w, (0, 0, 0, max_w - w.shape[0]))
436
+ whisper_batch.append(w_pad)
437
+ raw_data.append({
438
+ "text_tokens": b["text_tokens"],
439
+ "snac_tokens": b["snac_tokens"],
440
+ "word_alignments": b.get("word_alignments"),
441
+ "answer_text": b.get("answer_text", "")
442
+ })
443
+
444
+ return {
445
+ "whisper": torch.stack(whisper_batch),
446
+ "raw_data": raw_data
447
+ }
448
+
449
+
450
+ # ============================================================
451
+ # DataLoader Factory
452
+ # ============================================================
453
+ class DataLoaderFactory:
454
+ """
455
+ Factory for creating DataLoaders with optimal settings.
456
+
457
+ Single Responsibility: Only handles DataLoader creation.
458
+ Open/Closed: Can extend multiprocessing strategies without modification.
459
+
460
+ Optimizations:
461
+ - Sequence length bucketing for reduced padding
462
+ - Optimal worker count based on system resources
463
+ """
464
+
465
+ @staticmethod
466
+ def get_optimal_workers() -> int:
467
+ """Calculate optimal num_workers based on system resources."""
468
+ if not torch.cuda.is_available():
469
+ return 0
470
+
471
+ try:
472
+ import os
473
+ num_gpus = torch.cuda.device_count()
474
+ cpu_cores = os.cpu_count() or 4
475
+ max_workers = max(1, cpu_cores // 2)
476
+
477
+ # 2 workers per GPU, capped by CPU
478
+ ideal_workers = num_gpus * 2
479
+ num_workers = min(ideal_workers, max_workers)
480
+
481
+ # Check VRAM pressure
482
+ try:
483
+ free_bytes, _ = torch.cuda.mem_get_info(0)
484
+ if free_bytes / 1024**3 < 5:
485
+ num_workers = min(num_workers, 1)
486
+ except Exception:
487
+ pass
488
+
489
+ return max(0, num_workers)
490
+ except Exception:
491
+ return 2
492
+
493
+ @classmethod
494
+ def create(
495
+ cls,
496
+ dataset: Dataset,
497
+ batch_size: int,
498
+ shuffle: bool = True,
499
+ collate_fn: Callable = collate_simple,
500
+ verbose: bool = True,
501
+ use_bucketing: bool = True,
502
+ ) -> DataLoader:
503
+ """
504
+ Create DataLoader with optimal settings.
505
+
506
+ Args:
507
+ dataset: The dataset to load from
508
+ batch_size: Batch size
509
+ shuffle: Whether to shuffle data
510
+ collate_fn: Function to collate samples
511
+ verbose: Whether to log details
512
+ use_bucketing: Use sequence length bucketing (reduces padding overhead)
513
+ """
514
+ optimal_workers = cls.get_optimal_workers()
515
+
516
+ # Try to use sequence length bucketing
517
+ batch_sampler = None
518
+ if use_bucketing and shuffle:
519
+ try:
520
+ # Get sequence lengths from dataset
521
+ lengths = cls._get_sequence_lengths(dataset)
522
+ if lengths and len(lengths) > batch_size * 10: # Only bucket if enough samples
523
+ batch_sampler = BucketBatchSampler(
524
+ lengths=lengths,
525
+ batch_size=batch_size,
526
+ shuffle=True,
527
+ drop_last=False,
528
+ )
529
+ if verbose:
530
+ log(f"[DataLoader] Using sequence length bucketing ({len(batch_sampler)} batches)")
531
+ except Exception as e:
532
+ if verbose:
533
+ log(f"[DataLoader] Bucketing failed: {e}, using standard batching")
534
+
535
+ # Try different multiprocessing methods
536
+ for mp_method in ['spawn', 'fork', None]:
537
+ try:
538
+ loader = cls._try_create_loader(
539
+ dataset, batch_size, shuffle, collate_fn,
540
+ optimal_workers, mp_method, batch_sampler
541
+ )
542
+ if loader is not None:
543
+ if verbose and mp_method:
544
+ log(f"[DataLoader] Using '{mp_method}' with {optimal_workers} workers")
545
+ elif verbose:
546
+ log("[DataLoader] Using single-process mode")
547
+ return loader
548
+ except Exception as e:
549
+ if verbose:
550
+ log(f"[DataLoader] {mp_method} failed: {str(e)[:50]}...")
551
+ continue
552
+
553
+ # Final fallback
554
+ if verbose:
555
+ log("[DataLoader] Fallback to num_workers=0")
556
+ return DataLoader(
557
+ dataset,
558
+ batch_size=batch_size,
559
+ shuffle=shuffle,
560
+ collate_fn=collate_fn,
561
+ num_workers=0,
562
+ pin_memory=True
563
+ )
564
+
565
+ @staticmethod
566
+ def _get_sequence_lengths(dataset: Dataset) -> Optional[List[int]]:
567
+ """Extract sequence lengths from dataset for bucketing."""
568
+ # Check if dataset has get_sequence_lengths method
569
+ if hasattr(dataset, 'get_sequence_lengths'):
570
+ return dataset.get_sequence_lengths()
571
+
572
+ # For ConcatDataset, try to combine lengths from components
573
+ if isinstance(dataset, ConcatDataset):
574
+ lengths = []
575
+ for ds in dataset.datasets:
576
+ if hasattr(ds, 'get_sequence_lengths'):
577
+ lengths.extend(ds.get_sequence_lengths())
578
+ else:
579
+ return None # Can't get lengths for all, skip bucketing
580
+ return lengths
581
+
582
+ return None
583
+
584
+ @staticmethod
585
+ def _try_create_loader(
586
+ dataset: Dataset,
587
+ batch_size: int,
588
+ shuffle: bool,
589
+ collate_fn: Callable,
590
+ num_workers: int,
591
+ mp_method: Optional[str],
592
+ batch_sampler: Optional[Sampler] = None,
593
+ ) -> Optional[DataLoader]:
594
+ """Try to create DataLoader with given settings."""
595
+ import multiprocessing
596
+
597
+ # When using batch_sampler, don't set batch_size, shuffle, or sampler
598
+ common_kwargs = {
599
+ 'collate_fn': collate_fn,
600
+ 'pin_memory': True,
601
+ }
602
+
603
+ if batch_sampler is not None:
604
+ common_kwargs['batch_sampler'] = batch_sampler
605
+ else:
606
+ common_kwargs['batch_size'] = batch_size
607
+ common_kwargs['shuffle'] = shuffle
608
+
609
+ if mp_method and num_workers > 0:
610
+ mp_context = multiprocessing.get_context(mp_method)
611
+ loader = DataLoader(
612
+ dataset,
613
+ num_workers=num_workers,
614
+ multiprocessing_context=mp_context,
615
+ persistent_workers=True,
616
+ **common_kwargs
617
+ )
618
+ # Test if it works
619
+ test_iter = iter(loader)
620
+ del test_iter
621
+ return loader
622
+ else:
623
+ return DataLoader(
624
+ dataset,
625
+ num_workers=0,
626
+ **common_kwargs
627
+ )
628
+
629
+
630
+ def create_dataloader(
631
+ dataset: Dataset,
632
+ batch_size: int,
633
+ shuffle: bool = True,
634
+ verbose: bool = True,
635
+ use_bucketing: bool = True,
636
+ ) -> DataLoader:
637
+ """
638
+ Convenience function for creating DataLoaders.
639
+
640
+ Args:
641
+ dataset: Dataset to load from
642
+ batch_size: Batch size
643
+ shuffle: Whether to shuffle
644
+ verbose: Whether to log
645
+ use_bucketing: Use sequence length bucketing
646
+ """
647
+ return DataLoaderFactory.create(
648
+ dataset, batch_size, shuffle,
649
+ collate_fn=collate_simple, verbose=verbose,
650
+ use_bucketing=use_bucketing,
651
+ )
652
+
653
+
654
+ # ============================================================
655
+ # Dataset Loading Pipeline
656
+ # ============================================================
657
+ def load_datasets(
658
+ paths: List[str],
659
+ tokenizer,
660
+ max_audio_len: int = 500,
661
+ max_seq_len: int = 2048,
662
+ verbose: bool = True,
663
+ lazy_loading: bool = True, # Use memory-efficient lazy loading
664
+ ) -> Dataset:
665
+ """
666
+ Load and combine multiple datasets.
667
+
668
+ Args:
669
+ paths: List of dataset paths (files or directories)
670
+ tokenizer: Tokenizer for text encoding
671
+ max_audio_len: Maximum audio length
672
+ max_seq_len: Maximum sequence length
673
+ verbose: Whether to log progress
674
+ lazy_loading: Use memory-efficient lazy loading for batch directories
675
+
676
+ Returns:
677
+ Combined dataset
678
+ """
679
+ if verbose:
680
+ log("\nLoading datasets (lazy loading enabled)..." if lazy_loading else "\nLoading datasets...")
681
+
682
+ all_datasets = []
683
+
684
+ for path in paths:
685
+ path = Path(path)
686
+ try:
687
+ # Check if this is a batch directory (use lazy loading)
688
+ batches_dir = None
689
+ if path.name.endswith('.batches') and path.is_dir():
690
+ batches_dir = path
691
+ elif Path(f"{path}.batches").exists():
692
+ batches_dir = Path(f"{path}.batches")
693
+ elif path.is_dir():
694
+ batch_files = list(path.glob("batch_*.pt"))
695
+ if batch_files:
696
+ batches_dir = path
697
+
698
+ if batches_dir and lazy_loading:
699
+ # Use memory-efficient lazy loading
700
+ batch_files = sorted(batches_dir.glob("batch_*.pt"))
701
+ if batch_files:
702
+ dataset = LazyShardedDataset(
703
+ batch_files,
704
+ tokenizer,
705
+ max_audio_len=max_audio_len,
706
+ max_seq_len=max_seq_len,
707
+ cache_size=5, # Keep 5 batches in memory
708
+ verbose=verbose
709
+ )
710
+ all_datasets.append(dataset)
711
+ if verbose:
712
+ log(f" {path.name}: {len(dataset):,} samples (lazy loading)")
713
+ else:
714
+ # Fall back to full loading for single files
715
+ loader = ShardedDatasetLoader(verbose=verbose)
716
+ data = loader.load(str(path))
717
+ if data:
718
+ dataset = InterleavedDataset(
719
+ data, tokenizer,
720
+ max_audio_len=max_audio_len,
721
+ max_seq_len=max_seq_len
722
+ )
723
+ all_datasets.append(dataset)
724
+ if verbose:
725
+ log(f" {path.name}: {len(data):,} samples")
726
+
727
+ except FileNotFoundError as e:
728
+ if verbose:
729
+ log(f" [WARN] {e}")
730
+
731
+ if not all_datasets:
732
+ raise ValueError("No datasets loaded!")
733
+
734
+ return ConcatDataset(all_datasets) if len(all_datasets) > 1 else all_datasets[0]
training/interleaving.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Interleaved sequence creation for IST-LM training.
3
+
4
+ Single Responsibility: Only handles interleaved sequence generation.
5
+ """
6
+
7
+ import random
8
+ import torch
9
+ import torch.nn.functional as F
10
+ from typing import List, Dict, Tuple, Optional, Any
11
+ from .config import SNAC_BASE_OFFSET, SNAC_LAYERS_PER_FRAME, SNAC_LAYER_OFFSET, EOS_TOKEN
12
+
13
+
14
+ def apply_snac_offset(token_idx: int, position: int) -> int:
15
+ """
16
+ Apply position-based offset to SNAC token.
17
+
18
+ SNAC uses 7 tokens per frame with position-based offsets:
19
+ offset = SNAC_BASE_OFFSET + (position % 7) * 4096
20
+
21
+ Args:
22
+ token_idx: Raw SNAC token index
23
+ position: Position in the sequence
24
+
25
+ Returns:
26
+ Offset-adjusted token index
27
+ """
28
+ if int(token_idx) >= SNAC_BASE_OFFSET:
29
+ # Already has offset applied
30
+ return int(token_idx)
31
+ offset = SNAC_BASE_OFFSET + (position % SNAC_LAYERS_PER_FRAME) * SNAC_LAYER_OFFSET
32
+ return int(token_idx) + offset
33
+
34
+
35
+ def get_text_ratio(
36
+ global_step: int,
37
+ decay_steps: int = 300,
38
+ initial_ratio: float = 0.9,
39
+ min_ratio: float = 0.0
40
+ ) -> float:
41
+ """
42
+ Calculate text ratio based on training step (IST-LM schedule).
43
+
44
+ Schedule: Start at 90% text, decrease by 10% every decay_steps.
45
+ - Step 0-299: 0.9
46
+ - Step 300-599: 0.8
47
+ - Step 600-899: 0.7
48
+ - ...
49
+ - Step 2700+: 0.0 (pure audio)
50
+
51
+ Args:
52
+ global_step: Current training step
53
+ decay_steps: Steps between each 10% decay
54
+ initial_ratio: Starting text ratio
55
+ min_ratio: Minimum text ratio
56
+
57
+ Returns:
58
+ Current text ratio
59
+ """
60
+ num_decays = global_step // decay_steps
61
+ text_ratio = initial_ratio - (num_decays * 0.1)
62
+ return max(min_ratio, text_ratio)
63
+
64
+
65
+ def calculate_dynamic_decay_steps(
66
+ total_steps: int,
67
+ steps_per_epoch: int = None,
68
+ initial_ratio: float = 0.9,
69
+ final_audio_portion: float = 0.2
70
+ ) -> int:
71
+ """
72
+ Calculate decay_steps for scheduled interleaving.
73
+
74
+ Two modes:
75
+ 1. If steps_per_epoch provided: Complete decay in first epoch only,
76
+ remaining epochs are pure audio.
77
+ 2. Otherwise: Use final_audio_portion to spread decay across training.
78
+
79
+ Args:
80
+ total_steps: Total training steps
81
+ steps_per_epoch: Steps per epoch (if provided, decay completes in epoch 1)
82
+ initial_ratio: Starting text ratio (default 0.9)
83
+ final_audio_portion: Portion of training with p=0 (only used if steps_per_epoch=None)
84
+
85
+ Returns:
86
+ Calculated decay_steps
87
+ """
88
+ # Number of decay stages: 0.9 -> 0.0 = 9 steps (not 10)
89
+ num_decay_stages = int(initial_ratio / 0.1)
90
+
91
+ if steps_per_epoch is not None:
92
+ # Complete decay in first epoch - remaining epochs are pure audio
93
+ return max(1, steps_per_epoch // num_decay_stages)
94
+ else:
95
+ # Original behavior: spread across training
96
+ steps_until_pure_audio = int(total_steps * (1 - final_audio_portion))
97
+ return max(1, steps_until_pure_audio // num_decay_stages)
98
+
99
+
100
+ class InterleavingStrategy:
101
+ """
102
+ Base class for interleaving strategies.
103
+
104
+ Open/Closed: Can create new strategies without modifying this class.
105
+ """
106
+
107
+ def create_sequence(
108
+ self,
109
+ text_tokens: List[int],
110
+ snac_tokens: List[int],
111
+ text_ratio: float,
112
+ **kwargs
113
+ ) -> Tuple[List[int], List[bool]]:
114
+ """
115
+ Create interleaved sequence.
116
+
117
+ Args:
118
+ text_tokens: Text token IDs
119
+ snac_tokens: SNAC audio token IDs
120
+ text_ratio: Ratio of text vs audio (0.0 = pure audio)
121
+
122
+ Returns:
123
+ Tuple of (interleaved_tokens, is_audio_mask)
124
+ """
125
+ raise NotImplementedError
126
+
127
+
128
+ class PositionalInterleaving(InterleavingStrategy):
129
+ """
130
+ Positional interleaving strategy (fallback when no alignments).
131
+
132
+ Interleaves text and audio tokens based on fixed patterns
133
+ determined by text_ratio.
134
+ """
135
+
136
+ # Pattern lookup: text_ratio -> (text_per_chunk, frames_per_chunk)
137
+ PATTERNS = {
138
+ 0.9: (1, 3), # 1 text token + 3 audio frames
139
+ 0.7: (1, 5), # 1 text token + 5 audio frames
140
+ 0.5: (1, 7), # 1 text token + 7 audio frames
141
+ 0.3: (1, 10), # 1 text token + 10 audio frames
142
+ 0.0: (0, 1), # Pure audio
143
+ }
144
+
145
+ def create_sequence(
146
+ self,
147
+ text_tokens: List[int],
148
+ snac_tokens: List[int],
149
+ text_ratio: float,
150
+ **kwargs
151
+ ) -> Tuple[List[int], List[bool]]:
152
+ interleaved = []
153
+ is_audio_mask = []
154
+
155
+ if len(snac_tokens) == 0:
156
+ return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
157
+
158
+ # Group SNAC into frames of 7
159
+ frames = self._group_into_frames(snac_tokens)
160
+ if len(frames) == 0:
161
+ return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
162
+
163
+ # Get interleaving pattern
164
+ text_per_chunk, frames_per_chunk = self._get_pattern(text_ratio)
165
+
166
+ total_text = len(text_tokens)
167
+ total_frames = len(frames)
168
+
169
+ text_idx = 0
170
+ frame_idx = 0
171
+ snac_position = 0
172
+
173
+ while frame_idx < total_frames:
174
+ # Add text tokens
175
+ if text_per_chunk > 0 and text_idx < total_text:
176
+ for _ in range(text_per_chunk):
177
+ if text_idx < total_text:
178
+ interleaved.append(text_tokens[text_idx])
179
+ is_audio_mask.append(False)
180
+ text_idx += 1
181
+
182
+ # Add audio frames
183
+ for _ in range(frames_per_chunk):
184
+ if frame_idx < total_frames:
185
+ frame = frames[frame_idx]
186
+ for tok in frame:
187
+ interleaved.append(apply_snac_offset(tok, snac_position))
188
+ is_audio_mask.append(True)
189
+ snac_position += 1
190
+ frame_idx += 1
191
+
192
+ # Add remaining text (only if not pure audio mode)
193
+ if text_per_chunk > 0:
194
+ while text_idx < total_text:
195
+ interleaved.append(text_tokens[text_idx])
196
+ is_audio_mask.append(False)
197
+ text_idx += 1
198
+
199
+ # Add EOS
200
+ interleaved.append(EOS_TOKEN)
201
+ is_audio_mask.append(False)
202
+
203
+ return interleaved, is_audio_mask
204
+
205
+ def _group_into_frames(self, snac_tokens: List[int]) -> List[List[int]]:
206
+ """Group SNAC tokens into frames of 7."""
207
+ frames = []
208
+ for i in range(0, len(snac_tokens), SNAC_LAYERS_PER_FRAME):
209
+ frame = snac_tokens[i:i + SNAC_LAYERS_PER_FRAME]
210
+ if len(frame) == SNAC_LAYERS_PER_FRAME:
211
+ frames.append(frame)
212
+ return frames
213
+
214
+ def _get_pattern(self, text_ratio: float) -> Tuple[int, int]:
215
+ """Get interleaving pattern for given text_ratio."""
216
+ if text_ratio >= 0.9:
217
+ return self.PATTERNS[0.9]
218
+ elif text_ratio >= 0.7:
219
+ return self.PATTERNS[0.7]
220
+ elif text_ratio >= 0.5:
221
+ return self.PATTERNS[0.5]
222
+ elif text_ratio >= 0.3:
223
+ return self.PATTERNS[0.3]
224
+ else:
225
+ return self.PATTERNS[0.0]
226
+
227
+
228
+ class AlignedInterleaving(InterleavingStrategy):
229
+ """
230
+ Word-aligned interleaving strategy.
231
+
232
+ Uses word alignments to semantically replace audio spans
233
+ with corresponding text tokens.
234
+ """
235
+
236
+ def create_sequence(
237
+ self,
238
+ text_tokens: List[int],
239
+ snac_tokens: List[int],
240
+ text_ratio: float,
241
+ word_alignments: Optional[List[Dict]] = None,
242
+ tokenizer=None,
243
+ answer_text: str = "",
244
+ **kwargs
245
+ ) -> Tuple[List[int], List[bool]]:
246
+ # Fall back to positional if no alignments
247
+ if not word_alignments or text_ratio <= 0:
248
+ return PositionalInterleaving().create_sequence(
249
+ text_tokens, snac_tokens, text_ratio
250
+ )
251
+
252
+ # Check for pre-computed tokens
253
+ has_precomputed = (
254
+ len(word_alignments) > 0 and
255
+ 'tokens' in word_alignments[0] and
256
+ word_alignments[0]['tokens']
257
+ )
258
+
259
+ if not has_precomputed and not tokenizer:
260
+ return PositionalInterleaving().create_sequence(
261
+ text_tokens, snac_tokens, text_ratio
262
+ )
263
+
264
+ interleaved = []
265
+ is_audio_mask = []
266
+
267
+ # Group SNAC into frames
268
+ frames = []
269
+ for i in range(0, len(snac_tokens), SNAC_LAYERS_PER_FRAME):
270
+ frame = snac_tokens[i:i + SNAC_LAYERS_PER_FRAME]
271
+ if len(frame) == SNAC_LAYERS_PER_FRAME:
272
+ frames.append(frame)
273
+
274
+ if len(frames) == 0:
275
+ return text_tokens + [EOS_TOKEN], [False] * (len(text_tokens) + 1)
276
+
277
+ total_frames = len(frames)
278
+
279
+ # Randomly select words to replace with text
280
+ num_words = len(word_alignments)
281
+ num_text_words = int(num_words * text_ratio)
282
+ word_indices = list(range(num_words))
283
+ random.shuffle(word_indices)
284
+ text_word_indices = set(word_indices[:num_text_words])
285
+
286
+ # Frame rate conversion
287
+ max_alignment_frame = max(wa['end_frame'] for wa in word_alignments)
288
+ frame_ratio = total_frames / max_alignment_frame if max_alignment_frame > total_frames * 2 else 1.0
289
+
290
+ snac_position = 0
291
+
292
+ for word_idx, alignment in enumerate(word_alignments):
293
+ word = alignment['word']
294
+ start_frame = int(alignment['start_frame'] * frame_ratio)
295
+ end_frame = min(int(alignment['end_frame'] * frame_ratio), total_frames)
296
+
297
+ if word_idx in text_word_indices:
298
+ # Replace audio with text
299
+ word_tokens = alignment.get('tokens', [])
300
+ if not word_tokens and tokenizer:
301
+ word_tokens = tokenizer.encode(word, add_special_tokens=False)
302
+
303
+ for tok in word_tokens:
304
+ interleaved.append(tok)
305
+ is_audio_mask.append(False)
306
+ snac_position = end_frame * SNAC_LAYERS_PER_FRAME
307
+ else:
308
+ # Keep audio
309
+ for f_idx in range(start_frame, end_frame):
310
+ if f_idx < total_frames:
311
+ frame = frames[f_idx]
312
+ for tok in frame:
313
+ interleaved.append(apply_snac_offset(tok, snac_position))
314
+ is_audio_mask.append(True)
315
+ snac_position += 1
316
+
317
+ # Add remaining frames
318
+ remaining_start = max(wa['end_frame'] for wa in word_alignments)
319
+ remaining_start = min(int(remaining_start * frame_ratio), total_frames)
320
+ for f_idx in range(remaining_start, total_frames):
321
+ frame = frames[f_idx]
322
+ for tok in frame:
323
+ interleaved.append(apply_snac_offset(tok, snac_position))
324
+ is_audio_mask.append(True)
325
+ snac_position += 1
326
+
327
+ # Add EOS
328
+ interleaved.append(EOS_TOKEN)
329
+ is_audio_mask.append(False)
330
+
331
+ return interleaved, is_audio_mask
332
+
333
+
334
+ def create_interleaved_sequence(
335
+ text_tokens: List[int],
336
+ snac_tokens: List[int],
337
+ text_ratio: float = 0.9,
338
+ word_alignments: Optional[List[Dict]] = None,
339
+ tokenizer=None,
340
+ answer_text: str = ""
341
+ ) -> Tuple[List[int], List[bool]]:
342
+ """
343
+ Create interleaved sequence (convenience function).
344
+
345
+ Automatically selects the best strategy based on available data.
346
+ """
347
+ if word_alignments and text_ratio > 0:
348
+ strategy = AlignedInterleaving()
349
+ else:
350
+ strategy = PositionalInterleaving()
351
+
352
+ return strategy.create_sequence(
353
+ text_tokens=text_tokens,
354
+ snac_tokens=snac_tokens,
355
+ text_ratio=text_ratio,
356
+ word_alignments=word_alignments,
357
+ tokenizer=tokenizer,
358
+ answer_text=answer_text,
359
+ )
360
+
361
+
362
+ def apply_interleaving(
363
+ batch: Dict[str, Any],
364
+ text_ratio: float,
365
+ tokenizer=None,
366
+ max_seq_len: int = 2048
367
+ ) -> Dict[str, torch.Tensor]:
368
+ """
369
+ Apply interleaving to a batch of samples.
370
+
371
+ Args:
372
+ batch: Batch from DataLoader with 'whisper' and 'raw_data'
373
+ text_ratio: Current text ratio
374
+ tokenizer: Tokenizer for on-the-fly encoding
375
+ max_seq_len: Maximum sequence length
376
+
377
+ Returns:
378
+ Batch with 'whisper', 'interleaved', 'is_audio_mask'
379
+ """
380
+ raw_data = batch["raw_data"]
381
+ sequences = []
382
+ max_seq = 0
383
+
384
+ for item in raw_data:
385
+ interleaved, is_audio = create_interleaved_sequence(
386
+ item["text_tokens"],
387
+ item["snac_tokens"],
388
+ text_ratio,
389
+ word_alignments=item.get("word_alignments"),
390
+ tokenizer=tokenizer,
391
+ answer_text=item.get("answer_text", "")
392
+ )
393
+
394
+ # Truncate if needed
395
+ if len(interleaved) > max_seq_len:
396
+ interleaved = interleaved[:max_seq_len]
397
+ is_audio = is_audio[:max_seq_len]
398
+
399
+ sequences.append((interleaved, is_audio))
400
+ max_seq = max(max_seq, len(interleaved))
401
+
402
+ # Pad and stack
403
+ interleaved_batch = []
404
+ is_audio_batch = []
405
+
406
+ for interleaved, is_audio in sequences:
407
+ seq_tensor = torch.tensor(interleaved, dtype=torch.long)
408
+ mask_tensor = torch.tensor(is_audio, dtype=torch.bool)
409
+
410
+ seq_pad = F.pad(seq_tensor, (0, max_seq - len(interleaved)), value=-100)
411
+ mask_pad = F.pad(mask_tensor, (0, max_seq - len(is_audio)), value=False)
412
+
413
+ interleaved_batch.append(seq_pad)
414
+ is_audio_batch.append(mask_pad)
415
+
416
+ return {
417
+ "whisper": batch["whisper"],
418
+ "interleaved": torch.stack(interleaved_batch),
419
+ "is_audio_mask": torch.stack(is_audio_batch)
420
+ }
training/models.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model components for Speech-to-Speech training.
3
+
4
+ Single Responsibility: Only defines model architectures.
5
+ Open/Closed: Can extend with new adapters without modifying existing code.
6
+
7
+ Optimizations:
8
+ - Flash Attention 2 for memory-efficient attention (10-20x savings on long sequences)
9
+ - BFloat16 for better numerical stability than FP16
10
+ - Gradient checkpointing for memory savings
11
+ """
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+ from typing import Optional
16
+ from .config import (
17
+ DEFAULT_WHISPER_DIM,
18
+ DEFAULT_LLM_DIM,
19
+ DEFAULT_DOWNSAMPLE,
20
+ DEFAULT_INTERMEDIATE_DIM,
21
+ )
22
+ from .utils import log
23
+
24
+
25
+ class SpeechAdapter(nn.Module):
26
+ """
27
+ Speech adapter that maps Whisper features to LLM embedding space.
28
+
29
+ Architecture: 5× downsampling + FFN with intermediate dim
30
+
31
+ Based on LLaMA-Omni 2 design:
32
+ - Concatenates 5 consecutive Whisper frames
33
+ - Projects through 2-layer FFN
34
+ - Applies LayerNorm for stability
35
+
36
+ Args:
37
+ whisper_dim: Dimension of Whisper features (default: 1280)
38
+ llm_dim: Dimension of LLM embeddings (default: 3072)
39
+ downsample: Downsampling factor (default: 5)
40
+ intermediate_dim: Hidden dimension of FFN (default: 2048)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ whisper_dim: int = DEFAULT_WHISPER_DIM,
46
+ llm_dim: int = DEFAULT_LLM_DIM,
47
+ downsample: int = DEFAULT_DOWNSAMPLE,
48
+ intermediate_dim: int = DEFAULT_INTERMEDIATE_DIM
49
+ ):
50
+ super().__init__()
51
+ self.whisper_dim = whisper_dim
52
+ self.llm_dim = llm_dim
53
+ self.downsample = downsample
54
+ self.intermediate_dim = intermediate_dim
55
+
56
+ concat_dim = whisper_dim * downsample
57
+
58
+ self.ffn = nn.Sequential(
59
+ nn.Linear(concat_dim, intermediate_dim),
60
+ nn.GELU(),
61
+ nn.Linear(intermediate_dim, llm_dim),
62
+ nn.LayerNorm(llm_dim)
63
+ )
64
+
65
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
66
+ """
67
+ Forward pass.
68
+
69
+ Args:
70
+ x: Whisper features [B, T, D]
71
+
72
+ Returns:
73
+ LLM embeddings [B, T // downsample, llm_dim]
74
+ """
75
+ B, T, D = x.shape
76
+
77
+ # Ensure T is divisible by downsample
78
+ T_new = (T // self.downsample) * self.downsample
79
+ x = x[:, :T_new]
80
+
81
+ # Reshape: [B, T, D] -> [B, T // downsample, D * downsample]
82
+ x = x.reshape(B, T_new // self.downsample, D * self.downsample)
83
+
84
+ return self.ffn(x)
85
+
86
+ def get_num_params(self) -> int:
87
+ """Return total number of parameters."""
88
+ return sum(p.numel() for p in self.parameters())
89
+
90
+ def get_config(self) -> dict:
91
+ """Return configuration dict for serialization."""
92
+ return {
93
+ "whisper_dim": self.whisper_dim,
94
+ "llm_dim": self.llm_dim,
95
+ "downsample": self.downsample,
96
+ "intermediate_dim": self.intermediate_dim,
97
+ }
98
+
99
+ @classmethod
100
+ def from_config(cls, config: dict) -> 'SpeechAdapter':
101
+ """Create adapter from configuration dict."""
102
+ return cls(**config)
103
+
104
+
105
+ class ModelFactory:
106
+ """
107
+ Factory for creating models with consistent settings.
108
+
109
+ Single Responsibility: Only handles model instantiation.
110
+ Dependency Inversion: Depends on abstractions (config), not concretions.
111
+ """
112
+
113
+ @staticmethod
114
+ def create_adapter(
115
+ whisper_dim: int = DEFAULT_WHISPER_DIM,
116
+ llm_dim: int = DEFAULT_LLM_DIM,
117
+ dtype: torch.dtype = torch.float32,
118
+ checkpoint_path: Optional[str] = None
119
+ ) -> SpeechAdapter:
120
+ """
121
+ Create a SpeechAdapter, optionally loading from checkpoint.
122
+
123
+ Args:
124
+ whisper_dim: Whisper feature dimension
125
+ llm_dim: LLM embedding dimension
126
+ dtype: Tensor dtype
127
+ checkpoint_path: Optional path to checkpoint
128
+
129
+ Returns:
130
+ Initialized SpeechAdapter
131
+ """
132
+ adapter = SpeechAdapter(
133
+ whisper_dim=whisper_dim,
134
+ llm_dim=llm_dim,
135
+ ).to(dtype=dtype)
136
+
137
+ if checkpoint_path:
138
+ ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
139
+ if "adapter" in ckpt:
140
+ adapter.load_state_dict(ckpt["adapter"])
141
+ elif "state_dict" in ckpt:
142
+ adapter.load_state_dict(ckpt["state_dict"])
143
+ else:
144
+ adapter.load_state_dict(ckpt)
145
+
146
+ return adapter
147
+
148
+ @staticmethod
149
+ def create_llm(
150
+ model_path: str,
151
+ dtype: torch.dtype = torch.bfloat16,
152
+ freeze: bool = True,
153
+ gradient_checkpointing: bool = False,
154
+ use_flash_attention: bool = True,
155
+ verbose: bool = True,
156
+ ):
157
+ """
158
+ Create and configure the LLM with memory optimizations.
159
+
160
+ Args:
161
+ model_path: HuggingFace model path
162
+ dtype: Tensor dtype (BF16 recommended for stability)
163
+ freeze: Whether to freeze all parameters
164
+ gradient_checkpointing: Enable gradient checkpointing
165
+ use_flash_attention: Try to use Flash Attention 2 (10-20x memory savings)
166
+ verbose: Log configuration details
167
+
168
+ Returns:
169
+ Configured LLM model
170
+ """
171
+ from transformers import AutoModelForCausalLM
172
+
173
+ # Determine attention implementation
174
+ # Flash Attention 2 provides 10-20x memory savings on long sequences
175
+ # Requires: Ampere/Ada/Hopper GPU (RTX 30xx, 40xx, A100, H100)
176
+ attn_impl = "sdpa" # Default fallback
177
+
178
+ if use_flash_attention and torch.cuda.is_available():
179
+ try:
180
+ # Actually try to import flash_attn to verify it works
181
+ import flash_attn
182
+ # Check GPU capability (Flash Attention 2 requires SM 80+)
183
+ major, _ = torch.cuda.get_device_capability()
184
+ if major >= 8: # Ampere or newer (RTX 30xx, 40xx, A100, H100)
185
+ attn_impl = "flash_attention_2"
186
+ if verbose:
187
+ log(f"[LLM] Using Flash Attention 2 v{flash_attn.__version__} (10-20x memory savings)")
188
+ else:
189
+ if verbose:
190
+ log(f"[LLM] GPU SM {major}.x too old for Flash Attention 2, using SDPA")
191
+ except ImportError:
192
+ if verbose:
193
+ log("[LLM] flash_attn not installed, using SDPA")
194
+ except Exception as e:
195
+ if verbose:
196
+ log(f"[LLM] Flash Attention check failed: {e}, using SDPA")
197
+
198
+ # Load model with optimizations
199
+ try:
200
+ llm = AutoModelForCausalLM.from_pretrained(
201
+ model_path,
202
+ torch_dtype=dtype,
203
+ attn_implementation=attn_impl,
204
+ )
205
+ except Exception as e:
206
+ # Fallback if flash_attention_2 fails
207
+ if attn_impl == "flash_attention_2":
208
+ if verbose:
209
+ log(f"[LLM] Flash Attention failed ({e}), falling back to SDPA")
210
+ llm = AutoModelForCausalLM.from_pretrained(
211
+ model_path,
212
+ torch_dtype=dtype,
213
+ attn_implementation="sdpa",
214
+ )
215
+ else:
216
+ raise
217
+
218
+ if freeze:
219
+ for p in llm.parameters():
220
+ p.requires_grad = False
221
+ llm.eval()
222
+
223
+ if gradient_checkpointing:
224
+ llm.gradient_checkpointing_enable()
225
+ if verbose:
226
+ log("[LLM] Gradient checkpointing enabled")
227
+
228
+ return llm
229
+
230
+ @staticmethod
231
+ def apply_lora(
232
+ llm,
233
+ lora_config: 'LoRAConfig'
234
+ ):
235
+ """
236
+ Apply LoRA to an LLM.
237
+
238
+ Args:
239
+ llm: The LLM model
240
+ lora_config: LoRA configuration
241
+
242
+ Returns:
243
+ LLM with LoRA applied
244
+ """
245
+ from peft import get_peft_model
246
+ peft_config = lora_config.to_peft_config()
247
+ return get_peft_model(llm, peft_config)
training/trainer.py ADDED
@@ -0,0 +1,565 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training loop abstraction.
3
+
4
+ Single Responsibility: Handles the training loop logic.
5
+ Open/Closed: Base class can be extended for different training stages.
6
+ Liskov Substitution: Stage1Trainer and Stage2Trainer are interchangeable where Trainer is expected.
7
+
8
+ Optimizations implemented:
9
+ - OOM handling with proper recovery (PyTorch FAQ best practices)
10
+ - Gradient NaN/Inf detection and skipping
11
+ - Gradient norm monitoring for stability
12
+ - Dynamic sequence length based on text ratio
13
+ - CUDA memory fragmentation reduction
14
+ """
15
+
16
+ import os
17
+ import gc
18
+ import math
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from torch.optim.lr_scheduler import CosineAnnealingLR
22
+ from accelerate import Accelerator
23
+ from accelerate.utils import set_seed
24
+ from tqdm import tqdm
25
+ from typing import Optional, Dict, Any, Tuple
26
+ from abc import ABC, abstractmethod
27
+
28
+ # Reduce CUDA memory fragmentation (PyTorch recommendation)
29
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
30
+
31
+ from .config import TrainingConfig, GPUConfig
32
+ from .data import load_datasets, create_dataloader
33
+ from .models import SpeechAdapter, ModelFactory
34
+ from .interleaving import (
35
+ get_text_ratio,
36
+ calculate_dynamic_decay_steps,
37
+ apply_interleaving,
38
+ )
39
+ from .checkpoint import CheckpointManager, TrainingState
40
+ from .utils import (
41
+ log,
42
+ setup_cuda_optimizations,
43
+ setup_hf_login,
44
+ load_tokenizer,
45
+ should_enable_gradient_checkpointing,
46
+ write_step,
47
+ get_device_info,
48
+ limit_ram_usage,
49
+ get_ram_info,
50
+ )
51
+
52
+
53
+ class BaseTrainer(ABC):
54
+ """
55
+ Abstract base class for trainers.
56
+
57
+ Implements Template Method pattern: defines training skeleton,
58
+ subclasses implement specific steps.
59
+ """
60
+
61
+ def __init__(self, config: TrainingConfig):
62
+ self.config = config
63
+ self.accelerator: Optional[Accelerator] = None
64
+ self.tokenizer = None
65
+ self.adapter: Optional[SpeechAdapter] = None
66
+ self.llm = None
67
+ self.optimizer = None
68
+ self.scheduler = None
69
+ self.train_loader = None
70
+ self.checkpoint_manager: Optional[CheckpointManager] = None
71
+
72
+ # Training state
73
+ self.global_step = 0
74
+ self.start_epoch = 0
75
+ self.best_loss = float("inf")
76
+ self.current_text_ratio = config.initial_text_ratio
77
+
78
+ # Stability monitoring
79
+ self.nan_count = 0
80
+ self.oom_count = 0
81
+ self.last_grad_norm = 0.0
82
+ self.max_grad_norm_seen = 0.0
83
+
84
+ @property
85
+ def is_main(self) -> bool:
86
+ """Check if this is the main process."""
87
+ return self.accelerator is None or self.accelerator.is_main_process
88
+
89
+ @property
90
+ def device(self):
91
+ """Get current device."""
92
+ return self.accelerator.device if self.accelerator else torch.device("cpu")
93
+
94
+ def setup(self):
95
+ """Setup training environment."""
96
+ self._setup_accelerator()
97
+ self._setup_memory()
98
+ self._setup_tokenizer()
99
+ self._setup_data()
100
+ self._setup_models()
101
+ self._setup_optimizer()
102
+ self._setup_checkpoint_manager()
103
+ self._resume_if_needed()
104
+ self._prepare_for_training()
105
+
106
+ def _setup_accelerator(self):
107
+ """Initialize Accelerator."""
108
+ mixed_precision = "bf16" if torch.cuda.is_available() else None
109
+
110
+ self.accelerator = Accelerator(
111
+ gradient_accumulation_steps=self.config.grad_accum,
112
+ mixed_precision=mixed_precision,
113
+ )
114
+
115
+ set_seed(42)
116
+
117
+ if self.is_main:
118
+ self._log_setup_info()
119
+
120
+ def _log_setup_info(self):
121
+ """Log setup information."""
122
+ device_info = get_device_info()
123
+ gpu_config = GPUConfig.auto_detect()
124
+
125
+ log("=" * 60)
126
+ log(self._get_stage_name())
127
+ log("=" * 60)
128
+ log(f"Device: {device_info}")
129
+ log(f"GPU: {gpu_config.name} ({gpu_config.vram_gb}GB)")
130
+ log(f"Num processes: {self.accelerator.num_processes}")
131
+ log(f"Batch: {self.config.batch_size}, Grad accum: {self.config.grad_accum}")
132
+ log(f"LR: {self.config.learning_rate}, Epochs: {self.config.epochs}")
133
+ if getattr(self.config, 'no_decay', False):
134
+ log(f"Fixed text ratio: {self.config.initial_text_ratio} (no decay)")
135
+ else:
136
+ log(f"Initial text ratio: {self.config.initial_text_ratio}")
137
+ log("[Optimizations] OOM recovery, NaN detection, grad monitoring, expandable_segments")
138
+
139
+ def _setup_memory(self):
140
+ """Configure memory settings."""
141
+ if self.device.type == 'cuda':
142
+ setup_cuda_optimizations(self.config.vram_fraction)
143
+
144
+ ram_total, _ = get_ram_info()
145
+ ram_limit = self.config.ram_limit_gb or (ram_total * 0.80)
146
+ limit_ram_usage(ram_limit)
147
+
148
+ if self.is_main:
149
+ log(f"[Memory] VRAM: {self.config.vram_fraction*100:.0f}%, RAM: {ram_limit:.1f}GB")
150
+
151
+ def _setup_tokenizer(self):
152
+ """Load tokenizer."""
153
+ setup_hf_login()
154
+ self.tokenizer = load_tokenizer(self.config.model_path)
155
+
156
+ def _setup_data(self):
157
+ """Load datasets and create dataloader."""
158
+ if self.is_main:
159
+ log("\nLoading datasets...")
160
+
161
+ dataset = load_datasets(
162
+ self.config.data_paths,
163
+ self.tokenizer,
164
+ max_audio_len=self.config.max_audio_len,
165
+ max_seq_len=self.config.max_seq_len,
166
+ verbose=self.is_main,
167
+ )
168
+
169
+ # Apply demo/test mode
170
+ if self.config.test_mode:
171
+ dataset = torch.utils.data.Subset(dataset, range(min(5, len(dataset))))
172
+ self.config.batch_size = min(self.config.batch_size, len(dataset))
173
+ self.config.grad_accum = 1
174
+ elif self.config.demo_mode:
175
+ dataset = torch.utils.data.Subset(dataset, range(min(1000, len(dataset))))
176
+ self.config.batch_size = min(4, self.config.batch_size)
177
+
178
+ if self.is_main:
179
+ log(f"Total samples: {len(dataset):,}")
180
+
181
+ self.train_loader = create_dataloader(
182
+ dataset,
183
+ self.config.batch_size,
184
+ shuffle=True,
185
+ verbose=self.is_main,
186
+ )
187
+
188
+ @abstractmethod
189
+ def _setup_models(self):
190
+ """Setup models (implemented by subclasses)."""
191
+ pass
192
+
193
+ @abstractmethod
194
+ def _setup_optimizer(self):
195
+ """Setup optimizer (implemented by subclasses)."""
196
+ pass
197
+
198
+ @abstractmethod
199
+ def _setup_checkpoint_manager(self):
200
+ """Setup checkpoint manager (implemented by subclasses)."""
201
+ pass
202
+
203
+ def _resume_if_needed(self):
204
+ """Resume from checkpoint if specified."""
205
+ if not self.config.resume_from or not os.path.exists(self.config.resume_from):
206
+ return
207
+
208
+ if self.is_main:
209
+ log(f"\nResuming from: {self.config.resume_from}")
210
+
211
+ ckpt = self.checkpoint_manager.load(self.config.resume_from)
212
+ self._load_checkpoint(ckpt)
213
+
214
+ @abstractmethod
215
+ def _load_checkpoint(self, ckpt: Dict[str, Any]):
216
+ """Load checkpoint state (implemented by subclasses)."""
217
+ pass
218
+
219
+ def _prepare_for_training(self):
220
+ """Prepare models and optimizer for training."""
221
+ # Prepare with accelerator
222
+ prepared = self.accelerator.prepare(
223
+ self.adapter, self.llm, self.optimizer, self.train_loader
224
+ )
225
+ self.adapter, self.llm, self.optimizer, self.train_loader = prepared
226
+
227
+ # Calculate steps
228
+ self.steps_per_epoch = max(1, len(self.train_loader) // self.config.grad_accum)
229
+ self.total_steps = max(1, self.steps_per_epoch * self.config.epochs)
230
+ self.warmup_steps = int(self.total_steps * self.config.warmup_ratio)
231
+
232
+ # Calculate decay steps
233
+ if self.config.dynamic_decay:
234
+ self.effective_decay_steps = calculate_dynamic_decay_steps(
235
+ self.total_steps,
236
+ steps_per_epoch=self.steps_per_epoch, # Complete decay in epoch 1
237
+ initial_ratio=self.config.initial_text_ratio,
238
+ )
239
+ if self.is_main:
240
+ log(f"[Dynamic Decay] decay_steps={self.effective_decay_steps} (decay completes in epoch 1)")
241
+ else:
242
+ self.effective_decay_steps = self.config.decay_steps
243
+
244
+ # Setup scheduler
245
+ self.scheduler = CosineAnnealingLR(
246
+ self.optimizer,
247
+ T_max=max(1, self.total_steps - self.warmup_steps),
248
+ eta_min=1e-6,
249
+ )
250
+
251
+ if self.is_main:
252
+ log(f"Steps: {self.steps_per_epoch}/epoch, {self.total_steps} total, {self.warmup_steps} warmup")
253
+
254
+ @abstractmethod
255
+ def _get_stage_name(self) -> str:
256
+ """Get stage name for logging."""
257
+ pass
258
+
259
+ def train(self):
260
+ """Main training loop."""
261
+ if self.is_main:
262
+ log("\n" + "=" * 60)
263
+ log(f"STARTING {self._get_stage_name()}")
264
+ log("=" * 60)
265
+
266
+ for epoch in range(self.start_epoch, self.config.epochs):
267
+ self._train_epoch(epoch)
268
+
269
+ self._finish_training()
270
+
271
+ def _train_epoch(self, epoch: int):
272
+ """Train one epoch."""
273
+ self.adapter.train()
274
+ epoch_loss = 0
275
+ accum_loss = 0
276
+
277
+ pbar = tqdm(
278
+ self.train_loader,
279
+ desc=f"Epoch {epoch+1}/{self.config.epochs}",
280
+ disable=not self.is_main,
281
+ )
282
+
283
+ for batch_idx, raw_batch in enumerate(pbar):
284
+ loss = self._train_step(raw_batch, batch_idx)
285
+ accum_loss += loss
286
+
287
+ if self.accelerator.sync_gradients:
288
+ self._update_after_step(accum_loss, pbar)
289
+ epoch_loss += accum_loss
290
+ accum_loss = 0
291
+
292
+ self._finish_epoch(epoch, epoch_loss)
293
+
294
+ def _check_numerical_stability(
295
+ self,
296
+ loss: torch.Tensor,
297
+ params,
298
+ ) -> Tuple[bool, str]:
299
+ """
300
+ Check for NaN/Inf in loss and gradients.
301
+
302
+ Returns:
303
+ (is_stable, reason): Tuple of stability flag and reason if unstable
304
+ """
305
+ # Check loss
306
+ if torch.isnan(loss) or torch.isinf(loss):
307
+ return False, f"loss={loss.item()}"
308
+
309
+ # Check gradients
310
+ for name, param in params:
311
+ if param.grad is not None:
312
+ if torch.isnan(param.grad).any():
313
+ return False, f"NaN gradient in {name}"
314
+ if torch.isinf(param.grad).any():
315
+ return False, f"Inf gradient in {name}"
316
+
317
+ return True, ""
318
+
319
+ def _compute_grad_norm(self, params) -> float:
320
+ """Compute total gradient norm for monitoring."""
321
+ total_norm = 0.0
322
+ for _, param in params:
323
+ if param.grad is not None:
324
+ param_norm = param.grad.data.norm(2)
325
+ total_norm += param_norm.item() ** 2
326
+ return math.sqrt(total_norm)
327
+
328
+ def _train_step(self, raw_batch: Dict[str, Any], batch_idx: int) -> float:
329
+ """Single training step with OOM and NaN/Inf handling.
330
+
331
+ Stability features:
332
+ - OOM recovery outside except clause (PyTorch FAQ best practice)
333
+ - NaN/Inf detection in loss and gradients
334
+ - Gradient norm monitoring
335
+ - gc.collect() before empty_cache() for better cleanup
336
+
337
+ See: https://pytorch.org/docs/stable/notes/faq.html
338
+ """
339
+ # Update text ratio (skip if no_decay mode for Stage 1)
340
+ if not getattr(self.config, 'no_decay', False):
341
+ self.current_text_ratio = get_text_ratio(
342
+ self.global_step,
343
+ self.effective_decay_steps,
344
+ self.config.initial_text_ratio,
345
+ )
346
+ # else: keep current_text_ratio fixed at initial value
347
+
348
+ # Dynamic max_seq_len based on text ratio
349
+ dynamic_max_seq = self._get_dynamic_max_seq()
350
+
351
+ # Apply interleaving
352
+ batch = apply_interleaving(
353
+ raw_batch,
354
+ self.current_text_ratio,
355
+ tokenizer=self.tokenizer,
356
+ max_seq_len=dynamic_max_seq,
357
+ )
358
+
359
+ # Get adapter dtype for proper casting
360
+ adapter_dtype = next(self.adapter.parameters()).dtype
361
+ whisper = batch["whisper"].to(self.device, dtype=adapter_dtype)
362
+ interleaved = batch["interleaved"].to(self.device)
363
+
364
+ # Clear cache periodically
365
+ if batch_idx % 100 == 0 and self.device.type == 'cuda':
366
+ torch.cuda.empty_cache()
367
+
368
+ # Forward and backward with OOM and NaN handling
369
+ # Use flag pattern to move recovery outside except (PyTorch FAQ recommendation)
370
+ oom_error = False
371
+ nan_error = False
372
+ error_reason = ""
373
+ seq_len_for_log = interleaved.shape[1]
374
+ loss_value = 0.0
375
+
376
+ try:
377
+ with self.accelerator.accumulate(self.adapter):
378
+ loss = self._compute_loss(whisper, interleaved)
379
+
380
+ # Check for NaN/Inf in loss before backward
381
+ if torch.isnan(loss) or torch.isinf(loss):
382
+ nan_error = True
383
+ error_reason = f"loss={loss.item()}"
384
+ else:
385
+ self.accelerator.backward(loss)
386
+
387
+ if self.accelerator.sync_gradients:
388
+ # Check gradient stability
389
+ trainable_params = list(self._get_trainable_params_named())
390
+ is_stable, reason = self._check_numerical_stability(loss, trainable_params)
391
+
392
+ if not is_stable:
393
+ nan_error = True
394
+ error_reason = reason
395
+ else:
396
+ # Track gradient norm for monitoring
397
+ self.last_grad_norm = self._compute_grad_norm(trainable_params)
398
+ self.max_grad_norm_seen = max(self.max_grad_norm_seen, self.last_grad_norm)
399
+
400
+ # Clip gradients
401
+ self.accelerator.clip_grad_norm_(
402
+ [p for _, p in trainable_params],
403
+ self.config.max_grad_norm,
404
+ )
405
+
406
+ if not nan_error:
407
+ self.optimizer.step()
408
+ self.optimizer.zero_grad(set_to_none=True)
409
+ loss_value = loss.item()
410
+
411
+ except torch.cuda.OutOfMemoryError:
412
+ oom_error = True
413
+
414
+ # Recovery outside except clause (prevents memory leak from exception stack frame)
415
+ if oom_error:
416
+ self.oom_count += 1
417
+ if self.is_main:
418
+ log(f"[OOM #{self.oom_count}] Skipping batch {batch_idx} (seq_len={seq_len_for_log})")
419
+ del whisper, interleaved, batch
420
+ gc.collect()
421
+ torch.cuda.empty_cache()
422
+ self.optimizer.zero_grad(set_to_none=True)
423
+ return 0.0
424
+
425
+ if nan_error:
426
+ self.nan_count += 1
427
+ if self.is_main:
428
+ log(f"[NaN #{self.nan_count}] Skipping batch {batch_idx}: {error_reason}")
429
+ self.optimizer.zero_grad(set_to_none=True)
430
+ return 0.0
431
+
432
+ return loss_value
433
+
434
+ def _get_trainable_params_named(self):
435
+ """Get named trainable parameters for gradient checking."""
436
+ # Default: adapter parameters
437
+ for name, param in self.adapter.named_parameters():
438
+ if param.requires_grad:
439
+ yield f"adapter.{name}", param
440
+
441
+ def _get_dynamic_max_seq(self) -> int:
442
+ """Get dynamic max sequence length based on text ratio.
443
+
444
+ More conservative limits to prevent CUDA OOM.
445
+ RTX 4090 (24GB) at 80% VRAM can handle ~1280-1536 max seq with LLM.
446
+ """
447
+ # Base limit more conservative for memory safety
448
+ base_limit = min(self.config.max_seq_len, 1536)
449
+
450
+ if self.current_text_ratio >= 0.7:
451
+ return base_limit
452
+ elif self.current_text_ratio >= 0.5:
453
+ return int(base_limit * 0.75) # ~1152
454
+ elif self.current_text_ratio >= 0.3:
455
+ return int(base_limit * 0.6) # ~922
456
+ else:
457
+ return int(base_limit * 0.5) # ~768
458
+
459
+ def _compute_loss(
460
+ self,
461
+ whisper: torch.Tensor,
462
+ interleaved: torch.Tensor,
463
+ ) -> torch.Tensor:
464
+ """
465
+ Compute training loss with numerical stability.
466
+
467
+ Numerical stability measures:
468
+ - Clamp logits to prevent extreme values
469
+ - Use BF16 which has better dynamic range than FP16
470
+ """
471
+ unwrapped_llm = self.accelerator.unwrap_model(self.llm)
472
+
473
+ # Forward through adapter
474
+ audio_embeds = self.adapter(whisper)
475
+
476
+ # Get token embeddings
477
+ input_tokens = interleaved[:, :-1].clamp(min=0)
478
+ with torch.no_grad():
479
+ token_embeds = unwrapped_llm.model.embed_tokens(input_tokens)
480
+
481
+ # Combine embeddings
482
+ combined = torch.cat([audio_embeds, token_embeds], dim=1)
483
+
484
+ # Forward through LLM
485
+ outputs = self.llm(inputs_embeds=combined, use_cache=False)
486
+ logits = outputs.logits
487
+
488
+ # Compute loss with numerical stability
489
+ audio_len = audio_embeds.shape[1]
490
+ seq_len = interleaved.shape[1]
491
+ seq_logits = logits[:, audio_len-1:audio_len-1+seq_len]
492
+
493
+ # Clamp logits to prevent numerical issues in softmax
494
+ # Large logits can cause overflow in exp() during cross_entropy
495
+ seq_logits = seq_logits.clamp(min=-100, max=100)
496
+
497
+ return F.cross_entropy(
498
+ seq_logits.reshape(-1, logits.size(-1)),
499
+ interleaved.reshape(-1),
500
+ ignore_index=-100,
501
+ label_smoothing=self.config.label_smoothing,
502
+ )
503
+
504
+ @abstractmethod
505
+ def _get_trainable_params(self):
506
+ """Get trainable parameters for gradient clipping."""
507
+ pass
508
+
509
+ def _update_after_step(self, accum_loss: float, pbar):
510
+ """Update after gradient accumulation step."""
511
+ # Learning rate schedule
512
+ if self.global_step < self.warmup_steps:
513
+ lr_scale = (self.global_step + 1) / self.warmup_steps
514
+ for pg in self.optimizer.param_groups:
515
+ pg["lr"] = self.config.learning_rate * lr_scale
516
+ else:
517
+ self.scheduler.step()
518
+
519
+ self.global_step += 1
520
+ if self.is_main:
521
+ write_step(self.global_step)
522
+
523
+ # Update progress bar with gradient norm for stability monitoring
524
+ avg_loss = accum_loss / self.config.grad_accum
525
+ pbar.set_postfix(
526
+ loss=f"{avg_loss:.4f}",
527
+ lr=f"{self.optimizer.param_groups[0]['lr']:.2e}",
528
+ text_ratio=f"{self.current_text_ratio:.1f}",
529
+ grad=f"{self.last_grad_norm:.1f}",
530
+ )
531
+
532
+ # Log warning if gradient norm is very high (potential instability)
533
+ if self.last_grad_norm > 100 and self.global_step % 50 == 0 and self.is_main:
534
+ log(f"[WARN] High gradient norm: {self.last_grad_norm:.1f} at step {self.global_step}")
535
+
536
+ # Save checkpoint
537
+ if self.global_step % self.config.save_steps == 0 and self.is_main:
538
+ self._save_step_checkpoint(accum_loss)
539
+
540
+ @abstractmethod
541
+ def _save_step_checkpoint(self, loss: float):
542
+ """Save step checkpoint (implemented by subclasses)."""
543
+ pass
544
+
545
+ @abstractmethod
546
+ def _finish_epoch(self, epoch: int, epoch_loss: float):
547
+ """Finish epoch (implemented by subclasses)."""
548
+ pass
549
+
550
+ def _finish_training(self):
551
+ """Finish training."""
552
+ self.accelerator.wait_for_everyone()
553
+
554
+ if self.is_main:
555
+ self.checkpoint_manager.wait_for_saves()
556
+ log("\n" + "=" * 60)
557
+ log(f"{self._get_stage_name()} COMPLETE!")
558
+ log(f"Best loss: {self.best_loss:.4f}")
559
+ log(f"Final text_ratio: {self.current_text_ratio:.1f}")
560
+ log(f"Max gradient norm seen: {self.max_grad_norm_seen:.2f}")
561
+ if self.oom_count > 0:
562
+ log(f"OOM errors recovered: {self.oom_count}")
563
+ if self.nan_count > 0:
564
+ log(f"NaN/Inf errors recovered: {self.nan_count}")
565
+ log("=" * 60)
training/utils.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility functions for training.
3
+
4
+ Single Responsibility: General utilities that don't fit elsewhere.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import torch
10
+ from typing import Tuple, Optional
11
+ from dataclasses import dataclass
12
+
13
+
14
+ # ============================================================
15
+ # Logging
16
+ # ============================================================
17
+ _verbose = True
18
+
19
+
20
+ def setup_logging(verbose: bool = True):
21
+ """Configure logging verbosity."""
22
+ global _verbose
23
+ _verbose = verbose
24
+
25
+
26
+ def log(msg: str):
27
+ """Log message to stdout with flush."""
28
+ if _verbose:
29
+ print(msg)
30
+ sys.stdout.flush()
31
+
32
+
33
+ # ============================================================
34
+ # Device Information
35
+ # ============================================================
36
+ @dataclass
37
+ class DeviceInfo:
38
+ """Information about the compute device."""
39
+ device_type: str
40
+ device_name: str
41
+ vram_gb: float
42
+ ram_total_gb: float
43
+ ram_available_gb: float
44
+ num_gpus: int
45
+
46
+ def __str__(self) -> str:
47
+ parts = [f"Device: {self.device_type}"]
48
+ if self.device_name:
49
+ parts.append(f"({self.device_name})")
50
+ if self.vram_gb > 0:
51
+ parts.append(f"VRAM: {self.vram_gb:.0f}GB")
52
+ if self.num_gpus > 1:
53
+ parts.append(f"x{self.num_gpus} GPUs")
54
+ return " | ".join(parts)
55
+
56
+
57
+ def get_device_info() -> DeviceInfo:
58
+ """Get information about the compute device."""
59
+ device_type = "cpu"
60
+ device_name = ""
61
+ vram_gb = 0.0
62
+ num_gpus = 0
63
+
64
+ # CUDA
65
+ if torch.cuda.is_available():
66
+ device_type = "cuda"
67
+ num_gpus = torch.cuda.device_count()
68
+ try:
69
+ props = torch.cuda.get_device_properties(0)
70
+ device_name = props.name
71
+ vram_gb = props.total_memory / (1024**3)
72
+ except Exception:
73
+ pass
74
+
75
+ # MPS
76
+ elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
77
+ device_type = "mps"
78
+ device_name = "Apple Silicon"
79
+ num_gpus = 1
80
+
81
+ # RAM info
82
+ ram_total, ram_available = get_ram_info()
83
+
84
+ return DeviceInfo(
85
+ device_type=device_type,
86
+ device_name=device_name,
87
+ vram_gb=vram_gb,
88
+ ram_total_gb=ram_total,
89
+ ram_available_gb=ram_available,
90
+ num_gpus=num_gpus,
91
+ )
92
+
93
+
94
+ def get_ram_info() -> Tuple[float, float]:
95
+ """Get RAM info in GB (total, available)."""
96
+ try:
97
+ import psutil
98
+ total = psutil.virtual_memory().total / 1024**3
99
+ available = psutil.virtual_memory().available / 1024**3
100
+ return total, available
101
+ except ImportError:
102
+ pass
103
+
104
+ try:
105
+ import subprocess
106
+ result = subprocess.run(
107
+ ['free', '-b'],
108
+ capture_output=True, text=True
109
+ )
110
+ lines = result.stdout.strip().split('\n')
111
+ if len(lines) >= 2:
112
+ parts = lines[1].split()
113
+ total = float(parts[1]) / 1024**3
114
+ available = float(parts[6]) / 1024**3 if len(parts) > 6 else float(parts[3]) / 1024**3
115
+ return total, available
116
+ except Exception:
117
+ pass
118
+
119
+ return 0.0, 0.0
120
+
121
+
122
+ def log_memory_usage() -> str:
123
+ """Get current memory usage string."""
124
+ parts = []
125
+
126
+ if torch.cuda.is_available():
127
+ used = torch.cuda.memory_allocated() / 1024**3
128
+ reserved = torch.cuda.memory_reserved() / 1024**3
129
+ parts.append(f"GPU: {used:.2f}GB / {reserved:.2f}GB")
130
+
131
+ try:
132
+ import psutil
133
+ ram_used = psutil.virtual_memory().used / 1024**3
134
+ ram_total = psutil.virtual_memory().total / 1024**3
135
+ parts.append(f"RAM: {ram_used:.1f}GB / {ram_total:.1f}GB")
136
+ except ImportError:
137
+ pass
138
+
139
+ return " | ".join(parts)
140
+
141
+
142
+ # ============================================================
143
+ # Memory Management
144
+ # ============================================================
145
+ def limit_ram_usage(max_ram_gb: float):
146
+ """Limit RAM usage via resource limits."""
147
+ try:
148
+ import resource
149
+ max_bytes = int(max_ram_gb * 1024**3)
150
+ resource.setrlimit(resource.RLIMIT_AS, (max_bytes, max_bytes))
151
+ except Exception:
152
+ pass
153
+
154
+
155
+ def setup_cuda_optimizations(vram_fraction: float = 0.80):
156
+ """Configure CUDA optimizations."""
157
+ if not torch.cuda.is_available():
158
+ return
159
+
160
+ torch.backends.cuda.matmul.allow_tf32 = True
161
+ torch.backends.cudnn.allow_tf32 = True
162
+ torch.backends.cudnn.benchmark = True
163
+ torch.set_float32_matmul_precision('high')
164
+
165
+ try:
166
+ torch.cuda.set_per_process_memory_fraction(vram_fraction)
167
+ torch.cuda.empty_cache()
168
+ except Exception:
169
+ pass
170
+
171
+
172
+ def should_enable_gradient_checkpointing(
173
+ vram_gb: float,
174
+ dynamic_decay: bool = False,
175
+ threshold_fraction: float = 0.4
176
+ ) -> bool:
177
+ """
178
+ Determine if gradient checkpointing should be enabled.
179
+
180
+ Args:
181
+ vram_gb: Total VRAM in GB
182
+ dynamic_decay: Whether using dynamic decay (longer sequences over time)
183
+ threshold_fraction: Fraction of VRAM that should be free
184
+
185
+ Returns:
186
+ Whether to enable gradient checkpointing
187
+ """
188
+ if not torch.cuda.is_available():
189
+ return False
190
+
191
+ # With dynamic_decay, sequences get longer over time
192
+ if dynamic_decay and vram_gb <= 32:
193
+ return True
194
+
195
+ # Check available VRAM
196
+ try:
197
+ torch.cuda.empty_cache()
198
+ free_bytes, total_bytes = torch.cuda.mem_get_info(0)
199
+ free_gb = free_bytes / 1024**3
200
+ threshold_gb = vram_gb * threshold_fraction
201
+ return free_gb < threshold_gb
202
+ except Exception:
203
+ # Conservative: enable if VRAM < 20GB
204
+ return vram_gb < 20
205
+
206
+
207
+ # ============================================================
208
+ # Step Sharing (for DDP + DataLoader workers)
209
+ # ============================================================
210
+ STEP_FILE = "/tmp/training_step.txt"
211
+
212
+
213
+ def write_step(step: int):
214
+ """Write current training step to file (main process only)."""
215
+ try:
216
+ with open(STEP_FILE, "w") as f:
217
+ f.write(str(step))
218
+ except Exception:
219
+ pass
220
+
221
+
222
+ def read_step() -> int:
223
+ """Read current training step from file."""
224
+ try:
225
+ with open(STEP_FILE, "r") as f:
226
+ return int(f.read().strip())
227
+ except Exception:
228
+ return 0
229
+
230
+
231
+ # ============================================================
232
+ # HuggingFace Helpers
233
+ # ============================================================
234
+ def setup_hf_login():
235
+ """Setup HuggingFace login from environment."""
236
+ hf_token = os.environ.get("HF_TOKEN")
237
+ if hf_token:
238
+ try:
239
+ from huggingface_hub import login
240
+ login(token=hf_token)
241
+ return True
242
+ except Exception:
243
+ pass
244
+ return False
245
+
246
+
247
+ def load_tokenizer(model_path: str):
248
+ """Load tokenizer with proper padding token."""
249
+ from transformers import AutoTokenizer
250
+
251
+ tokenizer = AutoTokenizer.from_pretrained(model_path)
252
+ if tokenizer.pad_token is None:
253
+ tokenizer.pad_token = tokenizer.eos_token
254
+
255
+ return tokenizer