Files
ludwig-ai--ludwig/scripts/benchmark_training_pipeline.py
wehub-resource-sync 593b94c120
pytest / Unit Tests (push) Has been cancelled
pytest / Integration (integration_tests_a) (push) Has been cancelled
pytest / Integration (integration_tests_b) (push) Has been cancelled
pytest / Integration (integration_tests_c) (push) Has been cancelled
pytest / Integration (integration_tests_d) (push) Has been cancelled
pytest / Integration (integration_tests_e) (push) Has been cancelled
pytest / Integration (integration_tests_f) (push) Has been cancelled
pytest / Integration (integration_tests_g) (push) Has been cancelled
pytest / Integration (integration_tests_h) (push) Has been cancelled
pytest / Integration (integration_tests_i) (push) Has been cancelled
pytest / Integration (integration_tests_j) (push) Has been cancelled
pytest / Distributed (distributed_a) (push) Has been cancelled
pytest / Distributed (distributed_b) (push) Has been cancelled
pytest / Distributed (distributed_c) (push) Has been cancelled
pytest / Distributed (distributed_d) (push) Has been cancelled
pytest / Distributed (distributed_e) (push) Has been cancelled
pytest / Distributed (distributed_f) (push) Has been cancelled
pytest / Minimal Install (push) Has been cancelled
pytest / Event File (push) Has been cancelled
pytest (slow) / py-slow (push) Has been cancelled
Publish JSON Schema / publish-schema (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:49:20 +08:00

494 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Benchmark the training data pipeline to measure GPU utilization and bottlenecks.
Measures per-step timing for 7 pipeline configurations:
eager_local — pre-decoded numpy arrays in memory (zero fetch overhead)
lazy_local_sync — LazyColumn decode per batch, synchronous on main thread
lazy_local_pre2 — LazyColumn with prefetch_size=2 background thread
lazy_local_pre4 — LazyColumn with prefetch_size=4 background thread
lazy_cached_ep1 — CachedLazyColumn: epoch 1 (decode + write to memmap)
lazy_cached_ep2plus — CachedLazyColumn: epoch 2+ (read from memmap, no decode)
lazy_ray — RayDataset + _with_lazy_decode (distributed backend)
Per step, records:
t_fetch — time next_batch() blocks (= GPU idle time without prefetch)
t_gpu — time simulated GPU work takes
util_pct — GPU utilization = t_gpu / (t_fetch + t_gpu) × 100
GPU work is simulated by time.sleep(--gpu-work-ms / 1000), which gives precise
control over the fetch/GPU ratio independent of hardware. Vary --gpu-work-ms
to understand the regime you care about:
--gpu-work-ms 5 small model / fast GPU → decode usually dominates
--gpu-work-ms 30 mid-size model → decode can dominate
--gpu-work-ms 100 large model / slow GPU → GPU dominates, decode hidden
Run:
python scripts/benchmark_training_pipeline.py [options]
Requirements: torchaudio (for WAV generation), ray[data] (for lazy_ray mode).
"""
import argparse
import os
import sys
import tempfile
import time
from statistics import mean, median, quantiles
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# ─────────────────────────────────────────────────────────────────────────────
# Helpers: synthetic WAV files + metadata
# ─────────────────────────────────────────────────────────────────────────────
_SAMPLE_RATE = 16_000
_DURATION_S = 0.5
def _write_wav_files(dest_dir: str, n: int) -> list[str]:
import torch
import torchaudio
os.makedirs(dest_dir, exist_ok=True)
n_samples = int(_DURATION_S * _SAMPLE_RATE)
silence = torch.zeros(1, n_samples)
paths = []
for i in range(n):
p = os.path.join(dest_dir, f"audio_{i:05d}.wav")
torchaudio.save(p, silence, _SAMPLE_RATE)
paths.append(p)
return paths
def _lazy_audio_metadata(feature_dim: int = 8, max_length: int = 23, mode: str = "lazy") -> dict:
return {
"lazy": True,
"mode": mode,
"reshape": None,
"lazy_audio_params": {
"audio_feature_dict": {
"type": "fbank",
"window_length_in_s": 0.04,
"window_shift_in_s": 0.02,
"num_filter_bands": feature_dim,
},
"feature_dim": feature_dim,
"max_length": max_length,
"padding_value": 0.0,
"normalization_type": None,
},
}
def _make_decode_fn(feature_dim: int, max_length: int):
from ludwig.features.audio_feature import AudioFeatureMixin
p = _lazy_audio_metadata(feature_dim, max_length)["lazy_audio_params"]
return AudioFeatureMixin._make_lazy_decode_fn(
audio_feature_dict=p["audio_feature_dict"],
feature_dim=p["feature_dim"],
max_length=p["max_length"],
padding_value=p["padding_value"],
normalization_type=p["normalization_type"],
)
# ─────────────────────────────────────────────────────────────────────────────
# Timing loop
# ─────────────────────────────────────────────────────────────────────────────
StepTiming = dict # {'t_fetch': float, 't_gpu': float, 'epoch': int, 'step': int}
def _run_timing_loop(batcher, n_epochs: int, gpu_work_s: float, n_warmup: int) -> list[StepTiming]:
"""Run the batcher for n_epochs and return per-step timings after warmup."""
timings = []
global_step = 0
for epoch in range(n_epochs):
batcher.set_epoch(epoch, batcher.batch_size)
while not batcher.last_batch():
t0 = time.perf_counter()
_batch = batcher.next_batch()
t1 = time.perf_counter()
# Simulate GPU forward + backward pass
time.sleep(gpu_work_s)
t2 = time.perf_counter()
if global_step >= n_warmup:
timings.append(
{
"t_fetch": t1 - t0,
"t_gpu": t2 - t1,
"epoch": epoch,
"step": global_step,
}
)
global_step += 1
return timings
def _stats(values: list[float]) -> dict:
if not values:
return {"mean": 0, "p50": 0, "p95": 0, "p99": 0, "min": 0, "max": 0}
qs = quantiles(values, n=100) if len(values) >= 2 else [values[0]] * 99
return {
"mean": mean(values),
"p50": median(values),
"p95": qs[94] if len(qs) > 94 else max(values),
"p99": qs[98] if len(qs) > 98 else max(values),
"min": min(values),
"max": max(values),
}
def _analyze(timings: list[StepTiming], n_samples: int, n_epochs: int) -> dict:
if not timings:
return {}
fetch_ms = [t["t_fetch"] * 1000 for t in timings]
gpu_ms = [t["t_gpu"] * 1000 for t in timings]
util = [g / (f + g) * 100 for f, g in zip(fetch_ms, gpu_ms) if (f + g) > 0]
total_s = sum(t["t_fetch"] + t["t_gpu"] for t in timings)
sps = (n_samples * n_epochs) / total_s if total_s > 0 else 0
return {
"fetch_ms": _stats(fetch_ms),
"gpu_ms": _stats(gpu_ms),
"util_pct": _stats(util),
"sps": sps,
"n_steps": len(timings),
}
# ─────────────────────────────────────────────────────────────────────────────
# Mode: eager_local (pre-decoded arrays)
# ─────────────────────────────────────────────────────────────────────────────
def bench_eager_local(paths, batch_size, epochs, feature_dim, max_length, gpu_work_s, n_warmup):
from concurrent.futures import ThreadPoolExecutor
from ludwig.data.dataset.pandas import PandasDataset
decode_fn = _make_decode_fn(feature_dim, max_length)
with ThreadPoolExecutor(max_workers=min(16, len(paths))) as ex:
decoded = np.stack(list(ex.map(decode_fn, paths)))
proc_col = "audio_proc"
feature_name = "audio_0"
features = {proc_col: {"name": feature_name, "column": feature_name, "type": "audio"}}
# Non-lazy metadata: decoded array stored directly
training_set_metadata = {feature_name: {"lazy": False, "reshape": (feature_dim, max_length)}}
ds = PandasDataset(
{proc_col: decoded.reshape(len(paths), -1)},
features,
data_cache_fp=None,
training_set_metadata=training_set_metadata,
)
with ds.initialize_batcher(batch_size=batch_size, should_shuffle=False) as batcher:
return _run_timing_loop(batcher, epochs, gpu_work_s, n_warmup)
# ─────────────────────────────────────────────────────────────────────────────
# Mode: lazy_local (sync or prefetch)
# ─────────────────────────────────────────────────────────────────────────────
def bench_lazy_local(paths, batch_size, epochs, feature_dim, max_length, gpu_work_s, n_warmup, prefetch_size=0):
from ludwig.data.dataset.pandas import PandasDataset
proc_col = "audio_proc"
feature_name = "audio_0"
features = {proc_col: {"name": feature_name, "column": feature_name, "type": "audio"}}
training_set_metadata = {feature_name: _lazy_audio_metadata(feature_dim, max_length)}
ds = PandasDataset(
{proc_col: np.array(paths, dtype=object)},
features,
data_cache_fp=None,
training_set_metadata=training_set_metadata,
)
with ds.initialize_batcher(batch_size=batch_size, should_shuffle=False, prefetch_size=prefetch_size) as batcher:
return _run_timing_loop(batcher, epochs, gpu_work_s, n_warmup)
# ─────────────────────────────────────────────────────────────────────────────
# Mode: lazy_cached (CachedLazyColumn — decode+cache on ep1, memmap on ep2+)
# ─────────────────────────────────────────────────────────────────────────────
def bench_lazy_cached(
paths, batch_size, epochs, feature_dim, max_length, gpu_work_s, n_warmup
) -> tuple[list[StepTiming], list[StepTiming]]:
"""Run ``lazy_cached`` mode and return (ep1_timings, ep2plus_timings).
Epoch 0 is a mandatory cache-fill pass (not counted in either result set).
``ep1_timings`` covers the first measured epoch (decode + memmap write).
``ep2plus_timings`` covers all subsequent epochs (pure memmap reads).
"""
import tempfile
from ludwig.data.dataset.pandas import PandasDataset
proc_col = "audio_proc"
feature_name = "audio_0"
features = {proc_col: {"name": feature_name, "column": feature_name, "type": "audio"}}
training_set_metadata = {feature_name: _lazy_audio_metadata(feature_dim, max_length, mode="lazy_cached")}
with tempfile.TemporaryDirectory() as cache_dir:
# Place a fake data_cache_fp so _decoded_cache_path uses this directory.
fake_cache_fp = os.path.join(cache_dir, "train.parquet")
ds = PandasDataset(
{proc_col: np.array(paths, dtype=object)},
features,
data_cache_fp=fake_cache_fp,
training_set_metadata=training_set_metadata,
)
# Run (1 + epochs) epochs total; epoch 0 fills the cache, remaining epochs
# are measured. n_warmup applies within the measured epochs only.
total_epochs = 1 + epochs
all_timings: list[StepTiming] = []
global_step = 0
with ds.initialize_batcher(batch_size=batch_size, should_shuffle=False) as batcher:
for epoch in range(total_epochs):
batcher.set_epoch(epoch, batcher.batch_size)
step_in_epoch = 0
while not batcher.last_batch():
t0 = time.perf_counter()
_batch = batcher.next_batch()
t1 = time.perf_counter()
time.sleep(gpu_work_s)
t2 = time.perf_counter()
measured_epoch = epoch - 1 # epoch 0 is cache-fill, not measured
if epoch >= 1:
warmup_step = global_step - (batcher.steps_per_epoch) # steps since cache-fill ended
if warmup_step >= n_warmup:
all_timings.append(
{
"t_fetch": t1 - t0,
"t_gpu": t2 - t1,
"epoch": measured_epoch,
"step": global_step,
}
)
global_step += 1
step_in_epoch += 1
ep1_timings = [t for t in all_timings if t["epoch"] == 0]
ep2plus_timings = [t for t in all_timings if t["epoch"] >= 1]
return ep1_timings, ep2plus_timings
# ─────────────────────────────────────────────────────────────────────────────
# Mode: lazy_ray
# ─────────────────────────────────────────────────────────────────────────────
def bench_lazy_ray(paths, batch_size, epochs, feature_dim, max_length, gpu_work_s, n_warmup):
import pandas as pd
import ray
from ludwig.data.dataset.ray import RayDataset
proc_col = "audio_proc"
feature_name = "audio_0"
features = {proc_col: {"name": feature_name, "column": feature_name, "type": "audio"}}
training_set_metadata = {feature_name: _lazy_audio_metadata(feature_dim, max_length)}
df = pd.DataFrame({proc_col: paths})
ray_ds = RayDataset.__new__(RayDataset)
ray_ds.ds = ray.data.from_pandas(df)
ray_ds.features = features
ray_ds.training_set_metadata = training_set_metadata
ray_ds.data_cache_fp = None
ray_ds.data_parquet_fp = None
all_timings = []
for epoch in range(epochs):
with ray_ds.initialize_batcher(batch_size=batch_size, should_shuffle=False) as batcher:
step = 0
while not batcher.last_batch():
t0 = time.perf_counter()
_batch = batcher.next_batch()
t1 = time.perf_counter()
time.sleep(gpu_work_s)
t2 = time.perf_counter()
global_step = epoch * batcher.steps_per_epoch + step
if global_step >= n_warmup:
all_timings.append({"t_fetch": t1 - t0, "t_gpu": t2 - t1, "epoch": epoch, "step": global_step})
step += 1
return all_timings
# ─────────────────────────────────────────────────────────────────────────────
# Reporting
# ─────────────────────────────────────────────────────────────────────────────
_COL = 22
def _fmt_stat(s: dict, unit: str = "ms") -> str:
return f"{s['mean']:6.1f} (p95={s['p95']:5.1f}) {unit}"
def _print_report(results: dict, n_samples: int, n_epochs: int, gpu_work_ms: float):
print()
print(f"{'Mode':<26} {'t_fetch mean(p95)':>22} {'t_gpu mean(p95)':>22} {'util%':>7} {'sps':>8}")
print("─" * 90)
for mode, r in results.items():
if not r:
print(f" {mode:<24} FAILED")
continue
fetch = _fmt_stat(r["fetch_ms"])
gpu = _fmt_stat(r["gpu_ms"])
util = f"{r['util_pct']['mean']:5.1f}%"
sps = f"{r['sps']:>8,.0f}"
print(f" {mode:<24} {fetch:>22} {gpu:>22} {util} {sps}")
print()
# Show GPU-idle time breakdown
print("GPU idle analysis (t_fetch / (t_fetch + t_gpu) × 100 = idle %):")
baseline_sps = None
for mode, r in results.items():
if not r:
continue
idle = 100 - r["util_pct"]["mean"]
overhead = ""
if baseline_sps is None:
baseline_sps = r["sps"]
elif baseline_sps and r["sps"]:
ratio = baseline_sps / r["sps"]
overhead = f" ({ratio:.1f}× slower than {list(results.keys())[0]})"
print(f" {mode:<26} GPU idle {idle:5.1f}%{overhead}")
print()
print(f" GPU work per step: {gpu_work_ms:.0f} ms (simulated)")
print(f" Decode budget to match eager: ≤ {gpu_work_ms:.0f} ms per batch")
print()
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--n-samples", type=int, default=200)
parser.add_argument("--batch-size", type=int, default=32)
parser.add_argument("--epochs", type=int, default=3)
parser.add_argument("--feature-dim", type=int, default=8)
parser.add_argument("--max-length", type=int, default=23)
parser.add_argument(
"--gpu-work-ms",
type=float,
default=30.0,
help="Simulated GPU step time per batch in ms. Try 5 (small model) / 30 / 100 (large model).",
)
parser.add_argument("--n-warmup", type=int, default=3, help="Steps to discard for warm-up")
parser.add_argument("--skip-ray", action="store_true", help="Skip the Ray backend benchmarks")
args = parser.parse_args()
gpu_work_s = args.gpu_work_ms / 1000.0
with tempfile.TemporaryDirectory() as tmpdir:
print(f"\nGenerating {args.n_samples} WAV files ...", flush=True)
paths = _write_wav_files(tmpdir, args.n_samples)
print(" done.")
ray_ready = False
if not args.skip_ray:
try:
import ray
if not ray.is_initialized():
ray.init(ignore_reinit_error=True, num_cpus=4, include_dashboard=False)
ray_ready = True
print("Ray initialised.")
except Exception as e:
print(f"Ray not available ({e}), skipping lazy_ray.")
kw = {
"paths": paths,
"batch_size": args.batch_size,
"epochs": args.epochs,
"feature_dim": args.feature_dim,
"max_length": args.max_length,
"gpu_work_s": gpu_work_s,
"n_warmup": args.n_warmup,
}
configs = [
("eager_local", lambda: bench_eager_local(**kw)),
("lazy_local_sync", lambda: bench_lazy_local(**kw, prefetch_size=0)),
("lazy_local_pre2", lambda: bench_lazy_local(**kw, prefetch_size=2)),
("lazy_local_pre4", lambda: bench_lazy_local(**kw, prefetch_size=4)),
]
if ray_ready:
configs.append(("lazy_ray", lambda: bench_lazy_ray(**kw)))
print(
f"\n{'─' * 70}\n"
f" n_samples={args.n_samples} batch_size={args.batch_size} "
f"epochs={args.epochs} gpu_work={args.gpu_work_ms:.0f}ms\n"
f"{'─' * 70}\n"
)
results = {}
for name, fn in configs:
print(f"Running {name} ...", flush=True)
try:
timings = fn()
results[name] = _analyze(timings, args.n_samples, args.epochs)
r = results[name]
print(
f" fetch {r['fetch_ms']['mean']:.1f}ms gpu {r['gpu_ms']['mean']:.1f}ms util {r['util_pct']['mean']:.1f}% {r['sps']:,.0f} sps"
)
except Exception as e:
import traceback
results[name] = None
print(f" FAILED: {e}")
traceback.print_exc()
# lazy_cached is run once; ep1 and ep2+ timings are split and reported separately.
print("Running lazy_cached (ep1 + ep2+) ...", flush=True)
try:
ep1_timings, ep2plus_timings = bench_lazy_cached(**kw)
results["lazy_cached_ep1"] = _analyze(ep1_timings, args.n_samples, 1) if ep1_timings else {}
results["lazy_cached_ep2plus"] = (
_analyze(ep2plus_timings, args.n_samples, max(1, args.epochs - 1)) if ep2plus_timings else {}
)
for suffix in ("ep1", "ep2plus"):
r = results[f"lazy_cached_{suffix}"]
if r:
print(
f" lazy_cached_{suffix}: fetch {r['fetch_ms']['mean']:.1f}ms "
f"gpu {r['gpu_ms']['mean']:.1f}ms util {r['util_pct']['mean']:.1f}% {r['sps']:,.0f} sps"
)
except Exception as e:
import traceback
results["lazy_cached_ep1"] = None
results["lazy_cached_ep2plus"] = None
print(f" FAILED: {e}")
traceback.print_exc()
_print_report(results, args.n_samples, args.epochs, args.gpu_work_ms)
if ray_ready:
ray.shutdown()
if __name__ == "__main__":
main()