omini-model / datasets /create_dataset.py
marcos
feat: Refactor training with SOLID principles and add optimizations
e20f447
Raw
History Blame Contribute Delete
49.6 kB
#!/usr/bin/env python3
"""
Dataset Generator - Fully Async Pipeline
Q&A -> TTS (2 GPUs) -> Features (2 GPUs) -> Save
"""
import os
import sys
import re
import time
import gc
import logging
import multiprocessing as mp
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
import numpy as np
import requests
import torch
# Global logger
logger = None
def setup_logging(log_file=None):
"""Setup logging to both console and file."""
global logger
logger = logging.getLogger("dataset_generator")
logger.setLevel(logging.INFO)
logger.handlers.clear()
# Console handler
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter('%(message)s'))
logger.addHandler(console)
# File handler (if specified)
if log_file:
file_handler = logging.FileHandler(log_file, mode='a')
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter('%(asctime)s | %(message)s', datefmt='%H:%M:%S'))
logger.addHandler(file_handler)
return logger
def log(msg):
"""Log message to both console and file."""
global logger
if logger:
logger.info(msg)
# Force flush all handlers
for handler in logger.handlers:
handler.flush()
else:
print(msg)
sys.stdout.flush()
def load_dotenv(env_path=None):
"""Load environment variables from .env file."""
if env_path is None:
# Look for .env in current dir or parent dirs
for path in [Path(".env"), Path(__file__).parent.parent / ".env"]:
if path.exists():
env_path = path
break
if env_path and Path(env_path).exists():
with open(env_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
log(f"[ENV] Loaded from {env_path}")
# Load .env file
load_dotenv()
# Configuration
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
GROQ_MODEL = "openai/gpt-oss-20b"
GROQ_PARALLEL_REQUESTS = 10
QA_PER_REQUEST = 100
def get_num_gpus() -> int:
"""Auto-detect number of available GPUs."""
if not torch.cuda.is_available():
return 1
return max(1, torch.cuda.device_count())
# Auto-detect GPUs at import time (can be overridden by --gpus)
NUM_GPUS = get_num_gpus()
# Memory estimates per component (GB per item in batch)
MEMORY_PER_ITEM = {
"tts": 0.04, # ~40MB per TTS item (question+answer pair)
"whisper": 0.02, # ~20MB per Whisper encoding (16kHz audio)
"snac": 0.15, # ~150MB per SNAC encoding (24kHz audio)
}
# Default batch sizes (will be auto-adjusted based on VRAM)
DEFAULT_BATCH_SIZES = {
"tts": 200, # TTS batch (processes questions + answers)
"whisper": 8, # Whisper parallel workers (each uses GPU)
"snac": 50, # SNAC encoding batch
}
def get_gpu_vram_gb() -> float:
"""Get total GPU VRAM in GB."""
if not torch.cuda.is_available():
return 0
return torch.cuda.get_device_properties(0).total_memory / 1024**3
def calculate_batch_sizes(vram_gb: float = None, shared_gpu: bool = True) -> dict:
"""
Calculate optimal batch sizes based on available VRAM.
When shared_gpu=True (default), TTS and Features share the same GPU,
so batch sizes must be more conservative to avoid OOM.
VRAM tiers (with shared GPU adjustment):
- 80GB+ (H100/H200): Large batches
- 40-80GB (A100/H100-64GB): Medium-large batches
- 24-40GB (RTX 4090): Medium batches
- 16-24GB (RTX 3090/4080): Smaller batches
- <16GB: Minimum safe values
Returns dict with: tts, whisper (workers), snac batch sizes
"""
if vram_gb is None:
vram_gb = get_gpu_vram_gb()
# Determine scale factor based on VRAM
if vram_gb >= 80:
tts_scale = 1.0
whisper_workers = 8 # Parallel Whisper threads
snac_scale = 0.6
elif vram_gb >= 40:
tts_scale = 0.75
whisper_workers = 6
snac_scale = 0.5
elif vram_gb >= 24:
tts_scale = 0.5
whisper_workers = 4 # RTX 4090 - moderate parallelism
snac_scale = 0.4
elif vram_gb >= 16:
tts_scale = 0.33
whisper_workers = 2
snac_scale = 0.25
else:
tts_scale = 0.2
whisper_workers = 1
snac_scale = 0.15
# If not sharing GPU, can use more memory
if not shared_gpu:
snac_scale = min(1.0, snac_scale * 1.5)
whisper_workers = min(8, whisper_workers + 2)
# Calculate batch sizes
batch_sizes = {
"tts": max(10, int(DEFAULT_BATCH_SIZES["tts"] * tts_scale)),
"whisper": whisper_workers, # Thread count for parallel Whisper
"snac": max(8, int(DEFAULT_BATCH_SIZES["snac"] * snac_scale)),
}
return batch_sizes
def print_batch_config(batch_sizes: dict, vram_gb: float):
"""Print batch configuration for transparency."""
log(f"[Config] GPU VRAM: {vram_gb:.1f}GB")
log(f"[Config] Batch sizes: TTS={batch_sizes['tts']}, Whisper={batch_sizes['whisper']}, SNAC={batch_sizes['snac']}")
def parse_qa(content):
pairs = []
content = content.replace("**", "")
current_q, current_a = None, None
for line in content.split("\n"):
line = line.strip()
if not line:
continue
qm = re.match(r"^[\d\.\)\-\*]*\s*[Qq][:\.]?\s*(.+)", line)
am = re.match(r"^[\d\.\)\-\*]*\s*[Aa][:\.]?\s*(.+)", line)
if qm:
current_q = qm.group(1).strip()
elif am:
current_a = am.group(1).strip()
if current_q and current_a and 2 <= len(current_q.split()) <= 25 and len(current_a) > 3:
pairs.append({"q": current_q, "a": current_a})
current_q, current_a = None, None
return pairs
def make_groq_request(request_id: int) -> list:
headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}
prompt = f"""Generate {QA_PER_REQUEST} unique Q&A pairs on diverse topics.
Format: Q: [question]
A: [answer]
Questions 2-25 words, answers 1-3 sentences."""
for retry in range(3):
try:
response = requests.post(
"https://api.groq.com/openai/v1/chat/completions",
headers=headers,
json={"model": GROQ_MODEL, "messages": [{"role": "user", "content": prompt}], "max_tokens": 8000, "temperature": 1.0},
timeout=60
)
if response.status_code == 429:
time.sleep(2 ** retry)
continue
response.raise_for_status()
return parse_qa(response.json()["choices"][0]["message"]["content"])
except:
if retry < 2:
time.sleep(1)
return []
def qa_producer(target_count: int, tts_queue: mp.Queue, batch_size: int, status_queue: mp.Queue, num_workers: int = 2):
"""Produces Q&A batches for TTS - exactly target_count pairs."""
seen = set()
pairs = []
pending = []
batch_idx = 0
t0 = time.time()
while len(pairs) < target_count:
with ThreadPoolExecutor(max_workers=GROQ_PARALLEL_REQUESTS) as ex:
futures = [ex.submit(make_groq_request, i) for i in range(GROQ_PARALLEL_REQUESTS)]
for f in as_completed(futures):
for p in f.result():
if len(pairs) >= target_count:
break
norm = p["q"].lower().strip().rstrip("?")
if norm not in seen:
seen.add(norm)
pairs.append(p)
pending.append(p)
if len(pending) >= batch_size:
tts_queue.put((batch_idx, pending[:batch_size]))
batch_idx += 1
pending = pending[batch_size:]
if len(pairs) >= target_count:
break
status_queue.put(("qa", len(pairs), target_count, len(pairs)/(time.time()-t0)))
if pending:
tts_queue.put((batch_idx, pending))
batch_idx += 1
for _ in range(num_workers):
tts_queue.put(None)
status_queue.put(("qa_done", len(pairs), batch_idx))
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):
"""TTS worker - processes batches with VRAM-adjusted batch sizes on specific GPU."""
import torch
_orig_load = torch.load
torch.load = lambda *a, **kw: _orig_load(*a, **{**kw, 'weights_only': False})
# Set specific GPU for this worker (distribute across available GPUs with offset)
actual_gpu = gpu_offset + (gpu_id % num_gpus)
torch.cuda.set_device(actual_gpu)
print(f"[TTS-GPU{gpu_id}] Assigned to CUDA device {actual_gpu}")
# Patch JSON encoder to handle torch.dtype (transformers bug workaround)
import json
_orig_default = json.JSONEncoder.default
def _patched_default(self, obj):
if isinstance(obj, torch.dtype):
return str(obj).split('.')[-1] # torch.float32 -> "float32"
return _orig_default(self, obj)
json.JSONEncoder.default = _patched_default
from soprano import SopranoTTS
# Use lmdeploy backend for 2000x real-time speed (much faster than transformers)
# Scale decoder_batch_size based on VRAM
# Note: Soprano TTS only accepts "cuda", not "cuda:N"
# torch.cuda.set_device() already selected the correct GPU above
vram_gb = torch.cuda.get_device_properties(actual_gpu).total_memory / 1024**3
gpu_name = torch.cuda.get_device_properties(actual_gpu).name
# Check if GPU supports lmdeploy (Blackwell/RTX 50xx not supported yet)
use_lmdeploy = "5090" not in gpu_name and "5080" not in gpu_name and "B100" not in gpu_name
if use_lmdeploy:
try:
dec_batch = 32 if vram_gb >= 80 else (16 if vram_gb >= 40 else 8)
tts = SopranoTTS(
backend="lmdeploy", # Fastest backend
device="cuda", # Uses current device set by torch.cuda.set_device()
cache_size_mb=4000 if vram_gb >= 24 else 2000, # More cache = faster
decoder_batch_size=dec_batch, # Parallel decoding based on VRAM
)
print(f"[TTS-GPU{gpu_id}] Using lmdeploy backend (decoder_batch={dec_batch})")
except Exception as e:
print(f"[TTS-GPU{gpu_id}] lmdeploy failed ({e}), falling back to transformers")
tts = SopranoTTS(backend="transformers", device="cuda")
else:
print(f"[TTS-GPU{gpu_id}] Blackwell GPU detected ({gpu_name}), using transformers backend")
tts = SopranoTTS(backend="transformers", device="cuda")
# Use centralized batch size
tts_batch = batch_sizes.get("tts", 200)
print(f"[TTS-GPU{gpu_id}] Using batch size: {tts_batch}")
status_queue.put(("tts_ready", gpu_id))
processed = 0
t_start = time.time()
last_heartbeat = time.time()
while True:
# Send heartbeat every 30 seconds to show worker is alive
if time.time() - last_heartbeat > 30:
status_queue.put(("tts_heartbeat", gpu_id, processed))
last_heartbeat = time.time()
# Use timeout to allow heartbeat even when queue is empty
try:
item = tts_queue.get(timeout=5)
except:
continue # Timeout, send heartbeat and retry
if item is None:
break
batch_idx, pairs = item
# Process all pairs in one batch (batch=200 for max speed)
all_results = []
questions = [p["q"] for p in pairs]
answers = [p["a"] for p in pairs]
try:
# Generate all audio in one batch call
combined = questions + answers
all_audios = tts.infer_batch(combined)
q_audios = all_audios[:len(questions)]
a_audios = all_audios[len(questions):]
for j, p in enumerate(pairs):
q_np = q_audios[j].cpu().numpy() if hasattr(q_audios[j], 'numpy') else q_audios[j]
a_np = a_audios[j].cpu().numpy() if hasattr(a_audios[j], 'numpy') else a_audios[j]
all_results.append({
"question": p["q"], "answer": p["a"],
"q_audio": np.asarray(q_np, dtype=np.float32),
"a_audio": np.asarray(a_np, dtype=np.float32),
})
except Exception as e:
import traceback
status_queue.put(("tts_error", gpu_id, f"{str(e)}\n{traceback.format_exc()}"))
# Continue processing other batches instead of crashing
continue
if all_results:
feat_queue.put((batch_idx, all_results))
processed += len(all_results)
elapsed = time.time() - t_start
status_queue.put(("tts", gpu_id, processed, processed/elapsed, len(all_results)/(time.time()-t_start) if elapsed > 0 else 0))
# TTS worker sends None to feat_queue directly (ensures all batches are sent before close signal)
feat_queue.put(None)
status_queue.put(("tts_done", gpu_id, processed))
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):
"""Features worker - VRAM-adjusted batch sizes for Whisper + SNAC + NeMo NFA on specific GPU."""
import torch
_orig = torch.load
torch.load = lambda *a, **kw: _orig(*a, **{**kw, 'weights_only': False})
import torchaudio
import snac
import tempfile
import soundfile as sf
from transformers import AutoTokenizer, WhisperModel, WhisperFeatureExtractor
from huggingface_hub import login
# Set specific GPU for this worker (distribute across available GPUs with offset)
actual_gpu = gpu_offset + (gpu_id % num_gpus)
torch.cuda.set_device(actual_gpu)
device = f"cuda:{actual_gpu}"
print(f"[Features-GPU{gpu_id}] Assigned to CUDA device {actual_gpu}")
# HuggingFace auth for gated models
hf_token = os.environ.get("HF_TOKEN")
if hf_token:
try:
login(token=hf_token)
except Exception as e:
print(f"[WARN] HuggingFace login failed: {e}")
# Use Whisper Large V3 Turbo (50% faster, same 1280-dim output)
print("[Features] Loading Whisper large-v3-turbo (transformers)...")
whisper_model = WhisperModel.from_pretrained("openai/whisper-large-v3-turbo", torch_dtype=torch.float16).to(device).eval()
whisper_feature_extractor = WhisperFeatureExtractor.from_pretrained("openai/whisper-large-v3-turbo")
# Note: torch.compile is skipped for Whisper as it causes issues with conv1d
# The batched processing already provides significant speedup
print("[Features] Whisper Turbo loaded successfully")
snac_model = snac.SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device).eval()
# Load NeMo ASR model for forced alignment (GPU-accelerated)
nfa_model = None
try:
import nemo.collections.asr as nemo_asr
# Use FastConformer CTC for fast GPU-based alignment
nfa_model = nemo_asr.models.EncDecCTCModel.from_pretrained("nvidia/stt_en_fastconformer_ctc_large")
nfa_model = nfa_model.to(device).eval()
print("[Features] NeMo Forced Aligner loaded (GPU-accelerated)")
except Exception as e:
print(f"[WARN] NeMo NFA not available, using proportional alignment: {e}")
# Load tokenizer for pre-computing text tokens (Orpheus model)
tokenizer = None
for model_path in ["canopylabs/orpheus-3b-0.1-pretrained", "meta-llama/Llama-3.2-3B"]:
try:
tokenizer = AutoTokenizer.from_pretrained(model_path, token=hf_token)
print(f"[Features] Loaded tokenizer from {model_path}")
break
except Exception as e:
print(f"[WARN] Failed to load tokenizer from {model_path}: {e}")
if tokenizer is None:
print("[WARN] No tokenizer available - text_tokens will not be pre-computed")
# Use centralized batch sizes
snac_batch = batch_sizes.get("snac", 50)
whisper_workers = batch_sizes.get("whisper", 8)
print(f"[Features-GPU{gpu_id}] Using SNAC batch={snac_batch}, Whisper workers={whisper_workers}")
status_queue.put(("feat_ready", gpu_id))
def process_whisper(audio_data):
"""Process single audio with Whisper. GPU-accelerated resampling."""
# GPU-accelerated resampling
audio_tensor = torch.from_numpy(audio_data).to(device)
audio_16k = torchaudio.functional.resample(audio_tensor, 32000, 16000)
# Truncate to max 30 seconds (480000 samples at 16kHz) - Whisper limit
max_samples = 480000
if audio_16k.shape[0] > max_samples:
audio_16k = audio_16k[:max_samples]
audio_16k_np = audio_16k.cpu().numpy().astype(np.float32)
# Extract features using Whisper feature extractor
inputs = whisper_feature_extractor(audio_16k_np, sampling_rate=16000, return_tensors="pt")
input_features = inputs.input_features.to(device, dtype=torch.float16)
# Encode with Whisper
with torch.no_grad():
encoder_outputs = whisper_model.encoder(input_features)
# Return encoder hidden states [seq_len, 1280]
return encoder_outputs.last_hidden_state.squeeze(0).cpu().float()
def extract_snac_tokens(codes, idx):
"""Extract SNAC tokens with correct position-based offsets.
Each frame has 7 tokens (1 from codebook 0, 2 from codebook 1, 4 from codebook 2).
Each position within a frame needs a unique offset:
- Position 0: 128266 + 0*4096
- Position 1: 128266 + 1*4096
- Position 2: 128266 + 2*4096
- Position 3: 128266 + 3*4096
- Position 4: 128266 + 4*4096
- Position 5: 128266 + 5*4096
- Position 6: 128266 + 6*4096
"""
SNAC_BASE = 128266
tokens = []
for j in range(codes[0].shape[-1]):
# Position 0: codebook 0 token
tokens.append(codes[0][idx, j].item() + SNAC_BASE + 0 * 4096)
# Positions 1-2: codebook 1 tokens
if j * 2 + 1 < codes[1].shape[-1]:
tokens.append(codes[1][idx, j * 2].item() + SNAC_BASE + 1 * 4096)
tokens.append(codes[1][idx, j * 2 + 1].item() + SNAC_BASE + 2 * 4096)
# Positions 3-6: codebook 2 tokens
for k in range(4):
if j * 4 + k < codes[2].shape[-1]:
tokens.append(codes[2][idx, j * 4 + k].item() + SNAC_BASE + (3 + k) * 4096)
return tokens
def get_word_alignments_nfa(audio_data, text, sample_rate=32000):
"""Get word-level alignments using NeMo Forced Aligner (GPU-accelerated)."""
if nfa_model is None:
return None
try:
# Resample to 16kHz for NeMo ASR
audio_16k = torchaudio.functional.resample(
torch.from_numpy(audio_data), sample_rate, 16000
).numpy().astype(np.float32)
# Save to temp file (NeMo requires file path)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
temp_path = f.name
sf.write(temp_path, audio_16k, 16000)
# Run CTC alignment using NeMo
# This uses GPU-accelerated Viterbi decoding
with torch.no_grad():
# Transcribe to get log probs
hypotheses = nfa_model.transcribe(
[temp_path],
return_hypotheses=True,
batch_size=1
)
# Clean up temp file
os.unlink(temp_path)
# Extract word timestamps from hypothesis
if hypotheses and len(hypotheses) > 0:
hyp = hypotheses[0]
if hasattr(hyp, 'timestep') and hyp.timestep is not None:
# Use NeMo's word-level timestamps
word_alignments = []
words = text.split()
timestamps = hyp.timestep.get('word', [])
# Map recognized words to reference text
for i, word in enumerate(words):
if i < len(timestamps):
ts = timestamps[i]
start_time = ts.get('start', 0)
end_time = ts.get('end', start_time + 0.1)
else:
# Fallback: estimate from audio duration
audio_duration = len(audio_data) / sample_rate
word_start = (i / len(words)) * audio_duration
word_end = ((i + 1) / len(words)) * audio_duration
start_time, end_time = word_start, word_end
# Convert to SNAC frames (75 frames/second)
start_frame = int(start_time * 75)
end_frame = int(end_time * 75)
word_tokens = []
if tokenizer is not None:
word_tokens = tokenizer.encode(word, add_special_tokens=False)
word_alignments.append({
'word': word,
'start': start_time,
'end': end_time,
'start_frame': start_frame,
'end_frame': end_frame,
'tokens': word_tokens
})
return word_alignments
except Exception as e:
status_queue.put(("nfa_error", gpu_id, str(e)))
return None
def get_word_alignments_proportional(audio_data, text, sample_rate=32000):
"""Fallback: proportional word alignment based on character count.
Calculates frame indices that match actual SNAC output:
- SNAC operates at 24kHz with ~320 samples per frame (75 fps)
- Audio is resampled from sample_rate to 24kHz before SNAC
"""
words = text.split()
if not words:
return []
# Calculate actual SNAC frame count after resampling to 24kHz
# SNAC uses ~320 samples per frame at 24kHz
audio_24k_samples = len(audio_data) * 24000 / sample_rate
total_snac_frames = int(audio_24k_samples / 320)
if total_snac_frames == 0:
return []
total_chars = sum(len(w) for w in words)
if total_chars == 0:
return []
audio_duration = len(audio_data) / sample_rate
word_alignments = []
current_frame = 0
# Pre-tokenize all words in batch for efficiency
all_word_tokens = []
if tokenizer is not None:
for word in words:
all_word_tokens.append(tokenizer.encode(word, add_special_tokens=False))
else:
all_word_tokens = [[] for _ in words]
for i, word in enumerate(words):
# Distribute frames proportionally based on character count
word_frames = int((len(word) / total_chars) * total_snac_frames)
# Ensure last word gets remaining frames
if i == len(words) - 1:
end_frame = total_snac_frames
else:
end_frame = min(current_frame + max(1, word_frames), total_snac_frames)
start_frame = current_frame
# Calculate time from frames (for compatibility)
start_time = start_frame / 75.0
end_time = end_frame / 75.0
word_alignments.append({
'word': word,
'start': start_time,
'end': end_time,
'start_frame': start_frame,
'end_frame': end_frame,
'tokens': all_word_tokens[i]
})
current_frame = end_frame
return word_alignments
processed = 0
t_start = time.time()
last_heartbeat = time.time()
while True:
# Send heartbeat every 30 seconds to show worker is alive
if time.time() - last_heartbeat > 30:
status_queue.put(("feat_heartbeat", gpu_id, processed))
last_heartbeat = time.time()
# Use timeout to allow heartbeat even when queue is empty
try:
item = feat_queue.get(timeout=5)
except:
continue # Timeout, send heartbeat and retry
if item is None:
break
batch_idx, audio_batch = item
t0 = time.time()
try:
# 1. Parallel Whisper encoding with GPU-accelerated resampling
q_audios = [ad["q_audio"] for ad in audio_batch]
with ThreadPoolExecutor(max_workers=whisper_workers) as ex:
whisper_features = list(ex.map(process_whisper, q_audios))
# 2. SNAC encoding - GPU-batched with GPU resampling
a_audios = [ad["a_audio"] for ad in audio_batch]
all_tokens = []
for start in range(0, len(a_audios), snac_batch):
end = min(start + snac_batch, len(a_audios))
mini_audios = a_audios[start:end]
max_len = max(a.shape[0] for a in mini_audios)
padded = [np.pad(a, (0, max_len - len(a))) for a in mini_audios]
# GPU-accelerated resampling for SNAC
audios_24k = torch.stack([
torchaudio.functional.resample(
torch.from_numpy(a).to(device), 32000, 24000
).cpu()
for a in padded
])
with torch.no_grad():
codes = snac_model.encode(audios_24k.unsqueeze(1).to(device))
for i in range(len(mini_audios)):
all_tokens.append(extract_snac_tokens(codes, i))
torch.cuda.synchronize()
# Periodic GPU memory cleanup every 100 batches to prevent fragmentation
if processed > 0 and processed % (100 * len(audio_batch)) == 0:
torch.cuda.empty_cache()
gc.collect()
# 3. Build results with pre-computed text tokens and word alignments
results = []
for i, ad in enumerate(audio_batch):
answer_text = ad["answer"]
answer_audio = ad["a_audio"]
result = {
"whisper_features": whisper_features[i],
"snac_tokens": torch.tensor(all_tokens[i], dtype=torch.long),
"text": ad["question"],
"answer": answer_text
}
# Pre-tokenize answer text for training (if tokenizer available)
if tokenizer is not None:
text_tokens = tokenizer.encode(answer_text, add_special_tokens=False)
result["text_tokens"] = torch.tensor(text_tokens, dtype=torch.long)
# Generate word alignments for IST-LM interleaving
# Use proportional alignment (NFA disabled for now - causes hangs)
word_alignments = get_word_alignments_proportional(answer_audio, answer_text)
if word_alignments:
result["word_alignments"] = word_alignments
results.append(result)
batch_time = time.time() - t0
batch_rate = len(results) / batch_time if batch_time > 0 else 0
# Put results with timeout to prevent indefinite blocking
put_start = time.time()
max_put_attempts = 10
for attempt in range(max_put_attempts):
try:
result_queue.put((batch_idx, results), timeout=30)
break
except Exception as put_err:
if attempt < max_put_attempts - 1:
status_queue.put(("feat_warn", gpu_id, f"Queue full, retry {attempt+1}"))
time.sleep(1)
else:
status_queue.put(("feat_error", gpu_id, f"Failed to put results after {max_put_attempts} attempts"))
raise put_err
processed += len(results)
elapsed = time.time() - t_start
status_queue.put(("feat", gpu_id, processed, processed/elapsed, batch_rate))
# Clear intermediate tensors to prevent memory accumulation
del whisper_features, all_tokens, results
if processed % 500 == 0: # More aggressive cleanup every 500 items
torch.cuda.empty_cache()
gc.collect()
except Exception as e:
import traceback
error_msg = str(e)
status_queue.put(("feat_error", gpu_id, f"{error_msg}\n{traceback.format_exc()}"))
# Clear GPU memory aggressively
torch.cuda.empty_cache()
torch.cuda.synchronize()
gc.collect()
# If OOM, try to recover by reducing batch sizes
if "out of memory" in error_msg.lower() or "OOM" in error_msg:
status_queue.put(("feat_warn", gpu_id, "OOM detected, clearing memory..."))
time.sleep(2) # Give GPU time to recover
torch.cuda.empty_cache()
continue
status_queue.put(("feat_done", gpu_id, processed))
def main():
mp.set_start_method('spawn', force=True)
# Increase file descriptor limit
import resource
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (min(65536, hard), hard))
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--count", "--num_samples", type=int, default=100, dest="count")
parser.add_argument("--output", type=str, default="./data/dataset.pt")
parser.add_argument("--gpus", type=int, default=NUM_GPUS)
parser.add_argument("--resume", action="store_true", help="Resume from existing checkpoint")
parser.add_argument("--checkpoint-interval", type=int, default=1000, help="Save checkpoint every N items")
parser.add_argument("--log-file", type=str, default=None, help="Log file path (default: output.log)")
# Optional overrides for batch sizes (if not set, auto-calculated from VRAM)
parser.add_argument("--tts-batch", type=int, default=None)
parser.add_argument("--snac-batch", type=int, default=None)
parser.add_argument("--whisper-workers", type=int, default=None)
args = parser.parse_args()
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
# Setup logging (default to output path + .log)
log_file = args.log_file or (args.output + ".log")
setup_logging(log_file)
log(f"[Log] Writing to {log_file}")
# Calculate batch sizes based on GPU VRAM
vram_gb = get_gpu_vram_gb()
batch_sizes = calculate_batch_sizes(vram_gb)
# Apply command-line overrides if specified
if args.tts_batch is not None:
batch_sizes["tts"] = args.tts_batch
if args.snac_batch is not None:
batch_sizes["snac"] = args.snac_batch
if args.whisper_workers is not None:
batch_sizes["whisper"] = args.whisper_workers
log("=" * 60)
log("Dataset Generator - Fully Async Pipeline")
log(f"Target: {args.count} items, GPUs: {args.gpus}")
print_batch_config(batch_sizes, vram_gb)
log("=" * 60)
# Resume from checkpoint or output file if exists
checkpoint_path = args.output + ".checkpoint"
existing_items = []
start_count = 0
# Track resume state
resume_from_path = None
if args.resume:
# Check for existing data to resume from
# Priority: .new checkpoint (partial new items) + output file, or just output file
new_checkpoint = checkpoint_path + ".new"
new_items_count = 0
base_count = 0
# Check if we have new items checkpoint
if Path(new_checkpoint).exists():
try:
data = torch.load(new_checkpoint, map_location="cpu", weights_only=False, mmap=True)
new_items_count = len(data)
del data
log(f"[Resume] Found {new_items_count} new items in checkpoint")
except Exception as e:
log(f"[Resume] Failed to read {new_checkpoint}: {e}")
# Check output file for base items
if Path(args.output).exists():
try:
data = torch.load(args.output, map_location="cpu", weights_only=False, mmap=True)
base_count = len(data)
del data
resume_from_path = args.output
log(f"[Resume] Found {base_count} base items in {args.output}")
except Exception as e:
log(f"[Resume] Failed to read {args.output}: {e}")
# Total count is base + new
start_count = base_count + new_items_count
if start_count > 0:
log(f"[Resume] Total: {start_count} items ({base_count} base + {new_items_count} new), need {args.count - start_count} more")
else:
log("[Resume] No valid resume file found, starting fresh")
if start_count >= args.count:
log(f"[Resume] Already have {start_count} items, saving final...")
torch.save(existing_items[:args.count], args.output)
log(f"COMPLETE: {args.count} items saved to {args.output}")
return
remaining_count = args.count - start_count
log(f"[Main] Generating {remaining_count} new items...")
total_start = time.time()
# Use regular mp.Queue with NO size limits to prevent deadlocks
# Workers will block on put() if queue is full, causing stalls
# Memory is managed by batch sizes instead
tts_queue = mp.Queue() # No maxsize - prevents TTS blocking
feat_queue = mp.Queue() # No maxsize - prevents Features blocking
result_queue = mp.Queue() # No maxsize - prevents result collection blocking
status_queue = mp.Queue()
workers = []
# Get actual GPU count for worker assignment
actual_num_gpus = get_num_gpus() if torch.cuda.is_available() else 1
# With 4+ GPUs, separate TTS and Features to avoid OOM
# TTS uses GPUs 0 to (N/2-1), Features uses GPUs (N/2) to (N-1)
if actual_num_gpus >= 4:
tts_gpus = actual_num_gpus // 2 # First half for TTS
feat_gpus = actual_num_gpus - tts_gpus # Second half for Features
tts_gpu_offset = 0
feat_gpu_offset = tts_gpus
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}")
else:
tts_gpus = actual_num_gpus
feat_gpus = actual_num_gpus
tts_gpu_offset = 0
feat_gpu_offset = 0
log(f"\n[Main] Detected {actual_num_gpus} GPUs, sharing between TTS and Features")
# Adjust number of workers based on available GPUs
num_tts_workers = min(args.gpus, tts_gpus)
num_feat_workers = min(args.gpus, feat_gpus)
log(f"[Main] Spawning {num_tts_workers} TTS workers, {num_feat_workers} Features workers")
qa_proc = mp.Process(target=qa_producer, args=(remaining_count, tts_queue, batch_sizes["tts"], status_queue, num_tts_workers))
qa_proc.start()
workers.append(qa_proc)
for gpu_id in range(num_tts_workers):
p = mp.Process(target=tts_worker, args=(gpu_id, tts_queue, feat_queue, status_queue, batch_sizes, tts_gpus, tts_gpu_offset))
p.start()
workers.append(p)
for gpu_id in range(num_feat_workers):
p = mp.Process(target=features_worker, args=(gpu_id, feat_queue, result_queue, status_queue, batch_sizes, feat_gpus, feat_gpu_offset))
p.start()
workers.append(p)
log("[Pipeline] All workers started, monitoring...")
results = {}
tts_done_count = 0
feat_done_count = 0
total_items = 0
t0 = time.time()
tts_ready = 0
feat_ready = 0
expected_from_feat = {}
feat_queue_closed = False
last_checkpoint_count = 0
# Main loop with improved stall detection
last_result_time = time.time()
last_status_time = time.time()
last_heartbeat_time = {f"tts_{i}": time.time() for i in range(num_tts_workers)}
last_heartbeat_time.update({f"feat_{i}": time.time() for i in range(num_feat_workers)})
stall_warning_shown = False
errors = []
while True:
# Process ALL pending status messages
for _ in range(100): # Limit to prevent infinite loop
try:
msg = status_queue.get_nowait()
last_status_time = time.time()
msg_type = msg[0]
if msg_type == "tts_ready":
tts_ready += 1
log(f"[TTS-GPU{msg[1]}] Ready ({tts_ready}/{num_tts_workers})")
elif msg_type == "feat_ready":
feat_ready += 1
log(f"[Features-GPU{msg[1]}] Ready ({feat_ready}/{num_feat_workers})")
elif msg_type == "qa":
log(f"[Q&A] {msg[1]}/{msg[2]} | {msg[3]:.1f}/s")
elif msg_type == "qa_done":
log(f"[Q&A] Done: {msg[1]} pairs, {msg[2]} batches")
elif msg_type == "tts":
log(f"[TTS-GPU{msg[1]}] {msg[2]} items | avg {msg[3]:.1f}/s | batch {msg[4]:.1f}/s")
last_heartbeat_time[f"tts_{msg[1]}"] = time.time()
elif msg_type == "tts_done":
tts_done_count += 1
log(f"[TTS-GPU{msg[1]}] Done: {msg[2]} items")
elif msg_type == "feat":
log(f"[Feat-GPU{msg[1]}] {msg[2]} items | avg {msg[3]:.1f}/s | batch {msg[4]:.1f}/s")
last_heartbeat_time[f"feat_{msg[1]}"] = time.time()
elif msg_type == "feat_done":
feat_done_count += 1
expected_from_feat[msg[1]] = msg[2]
log(f"[Features-GPU{msg[1]}] Done: {msg[2]} items")
elif msg_type == "tts_heartbeat":
last_heartbeat_time[f"tts_{msg[1]}"] = time.time()
elif msg_type == "feat_heartbeat":
last_heartbeat_time[f"feat_{msg[1]}"] = time.time()
elif msg_type == "feat_warn":
log(f"[WARN] Features-GPU{msg[1]}: {msg[2]}")
elif "error" in msg_type:
log(f"[Error] {msg}")
errors.append(msg)
except:
break
if tts_done_count >= num_tts_workers and not feat_queue_closed:
log(f"[Main] TTS done, waiting for features to finish...")
feat_queue_closed = True
# Collect ALL available results (drain aggressively with non-blocking gets)
collected_this_round = 0
drain_start = time.time()
while time.time() - drain_start < 2.0: # Spend up to 2s draining
try:
batch_idx, items = result_queue.get_nowait()
results[batch_idx] = items
total_items += len(items)
collected_this_round += len(items)
last_result_time = time.time()
stall_warning_shown = False
except:
# No more items immediately available, wait briefly then check again
time.sleep(0.05)
try:
batch_idx, items = result_queue.get_nowait()
results[batch_idx] = items
total_items += len(items)
collected_this_round += len(items)
last_result_time = time.time()
stall_warning_shown = False
except:
break # Queue truly empty
if collected_this_round > 0:
elapsed = time.time() - t0
log(f"[Results] {total_items}/{remaining_count} | {total_items/elapsed:.1f}/s")
# Save checkpoint periodically (combine with any existing checkpoint)
if total_items - last_checkpoint_count >= args.checkpoint_interval:
log(f"[Checkpoint] Saving {total_items} new items...")
# Collect items from this run
items_this_run = []
for i in sorted(results.keys()):
items_this_run.extend(results[i])
# Load and combine with existing checkpoint if present
checkpoint_new_path = checkpoint_path + ".new"
all_checkpoint_items = []
if Path(checkpoint_new_path).exists():
try:
prev_items = torch.load(checkpoint_new_path, map_location="cpu", weights_only=False)
all_checkpoint_items = list(prev_items)
del prev_items
except:
pass
# Only add items not already in checkpoint
items_to_add = items_this_run[len(all_checkpoint_items):]
all_checkpoint_items.extend(items_to_add)
torch.save(all_checkpoint_items, checkpoint_new_path)
last_checkpoint_count = total_items
log(f"[Checkpoint] Saved {len(all_checkpoint_items)} total to {checkpoint_new_path}")
# Check exit conditions
if total_items >= remaining_count:
log(f"[Main] Target reached: {total_items}/{remaining_count}")
break
# If all feature workers done, drain remaining results
if feat_done_count >= num_feat_workers:
log(f"[Main] All workers done, draining queue...")
drain_start = time.time()
while time.time() - drain_start < 60.0:
try:
batch_idx, items = result_queue.get(timeout=0.5)
results[batch_idx] = items
total_items += len(items)
log(f"[Results] {total_items}/{remaining_count} | (drained)")
except:
time.sleep(0.2)
try:
batch_idx, items = result_queue.get_nowait()
results[batch_idx] = items
total_items += len(items)
log(f"[Results] {total_items}/{remaining_count} | (drained)")
except:
break
break
# Check if workers are still alive and responding
alive_workers = sum(1 for p in workers if p.is_alive())
time_since_result = time.time() - last_result_time
time_since_status = time.time() - last_status_time
# Check heartbeats - detect stuck workers
stuck_workers = []
for worker_id, last_hb in last_heartbeat_time.items():
if time.time() - last_hb > 120: # No heartbeat for 2 minutes
stuck_workers.append(worker_id)
if time_since_result > 30 and not stall_warning_shown:
queue_size = 0
try:
queue_size = result_queue.qsize()
except:
pass
log(f"[WARN] No results for 30s, {alive_workers} workers alive, queue ~{queue_size} items")
if stuck_workers:
log(f"[WARN] Stuck workers (no heartbeat >2min): {stuck_workers}")
stall_warning_shown = True
# Save checkpoint on stall detection (combine with existing checkpoint)
if time_since_result > 60 and total_items > last_checkpoint_count:
log(f"[Checkpoint] Stall detected, saving {total_items} items...")
items_this_run = []
for i in sorted(results.keys()):
items_this_run.extend(results[i])
# Load and combine with existing checkpoint
checkpoint_new_path = checkpoint_path + ".new"
all_checkpoint_items = []
if Path(checkpoint_new_path).exists():
try:
prev_items = torch.load(checkpoint_new_path, map_location="cpu", weights_only=False)
all_checkpoint_items = list(prev_items)
del prev_items
except:
pass
items_to_add = items_this_run[len(all_checkpoint_items):]
all_checkpoint_items.extend(items_to_add)
torch.save(all_checkpoint_items, checkpoint_new_path)
last_checkpoint_count = total_items
# Exit if truly stalled - but be smarter about it
# Only exit if: no results for 3min AND no heartbeats AND workers dead
all_workers_stuck = len(stuck_workers) >= (num_tts_workers + num_feat_workers)
if time_since_result > 180 and all_workers_stuck:
log(f"[WARN] All workers stuck, stopping with {total_items} items")
log(f"[WARN] Errors encountered: {len(errors)}")
for err in errors[-5:]: # Show last 5 errors
log(f" {err}")
break
if time_since_result > 600:
log(f"[WARN] No progress for 10min, stopping with {total_items} items")
break
# Save checkpoint before cleanup (combine with existing checkpoint)
if total_items > last_checkpoint_count:
log(f"[Checkpoint] Final save before cleanup: {total_items} items...")
items_this_run = []
for i in sorted(results.keys()):
items_this_run.extend(results[i])
# Load and combine with existing checkpoint
checkpoint_new_path = checkpoint_path + ".new"
all_checkpoint_items = []
if Path(checkpoint_new_path).exists():
try:
prev_items = torch.load(checkpoint_new_path, map_location="cpu", weights_only=False)
all_checkpoint_items = list(prev_items)
del prev_items
except:
pass
items_to_add = items_this_run[len(all_checkpoint_items):]
all_checkpoint_items.extend(items_to_add)
torch.save(all_checkpoint_items, checkpoint_new_path)
last_checkpoint_count = total_items
# Wait for workers to finish
log("[Main] Waiting for workers to finish...")
for p in workers:
p.join(timeout=5)
if p.is_alive():
p.terminate()
# Final drain
for _ in range(100):
try:
batch_idx, items = result_queue.get_nowait()
results[batch_idx] = items
total_items += len(items)
except:
break
# Collect new results from this run
new_items_this_run = []
for i in sorted(results.keys()):
new_items_this_run.extend(results[i])
log(f"[Main] Generated {len(new_items_this_run)} new items this run")
# Load any previously checkpointed new items and combine with this run's items
new_checkpoint = checkpoint_path + ".new"
new_items = []
if Path(new_checkpoint).exists():
try:
prev_new = torch.load(new_checkpoint, map_location="cpu", weights_only=False)
new_items = list(prev_new)
log(f"[Main] Loaded {len(new_items)} items from previous checkpoint")
del prev_new
except Exception as e:
log(f"[Main] Failed to load previous checkpoint: {e}")
# Add this run's items to the checkpoint items
new_items.extend(new_items_this_run)
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)})")
total_new = len(new_items)
if total_new == 0 and (not resume_from_path or start_count == 0):
log("[ERROR] No items collected! Check worker logs.")
sys.exit(1)
# Combine with base items if resuming
if resume_from_path and Path(resume_from_path).exists():
log(f"[Main] Loading base items from {resume_from_path}...")
base_data = torch.load(resume_from_path, map_location="cpu", weights_only=False, mmap=True)
base_count = len(base_data)
# Calculate how many base items to keep
items_needed_from_base = min(base_count, args.count - total_new)
log(f"[Main] Combining {items_needed_from_base} base + {total_new} new items...")
final_items = list(base_data[:items_needed_from_base]) + new_items
del base_data
else:
final_items = new_items
# Trim to target count
final_items = final_items[:args.count]
# Save final dataset
log(f"[Main] Saving {len(final_items)} items to {args.output}...")
torch.save(final_items, args.output)
# Remove checkpoint files if complete
if len(final_items) >= args.count:
for cp in [checkpoint_path, checkpoint_path + ".new"]:
if Path(cp).exists():
Path(cp).unlink()
log(f"[Cleanup] Removed {cp}")
total_time = time.time() - total_start
log("\n" + "=" * 60)
log(f"COMPLETE: {len(final_items)} items saved to {args.output}")
if start_count > 0:
log(f" (resumed from {start_count}, added {len(final_items) - start_count} new)")
log(f"Total time: {total_time:.1f}s ({total_time/60:.1f}m)")
log(f"Throughput: {(len(final_items) - start_count)/total_time:.2f} items/s")
log("=" * 60)
if __name__ == "__main__":
main()