cham-ocr-studio / ocr-training /scripts /benchmark_orchestrator.py
phucsd's picture
Upload folder using huggingface_hub
770f650 verified
Raw History Blame Contribute Delete
20.5 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Automated Multi-GPU Benchmark & Profiling Orchestrator.
Discovers optimal num_workers, sweeps batch sizes, performs binary search on OOM,
and computes 40-epoch economic projections (Time, Cost, IPS/$).
"""
import os
import sys
import time
import json
import argparse
import subprocess
import shutil
if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
if sys.stderr and hasattr(sys.stderr, 'reconfigure'):
try:
sys.stderr.reconfigure(encoding='utf-8')
except Exception:
pass
PRICING_TABLE = {
"A100": 2.19,
"A100-SXM4-40GB": 2.19,
"NVIDIA A100": 2.19,
"L4": 0.79,
"NVIDIA L4": 0.79,
"L40S": 2.14,
"NVIDIA L40S": 2.14,
"T4": 0.40,
"NVIDIA T4": 0.40,
}
TOTAL_DATASET_SAMPLES = 250000
TOTAL_EPOCHS = 40
TOTAL_TRAIN_SAMPLES = TOTAL_DATASET_SAMPLES * TOTAL_EPOCHS # 10,000,000
def detect_gpu_name():
try:
res = subprocess.run(
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
capture_output=True, text=True, timeout=2.0
)
if res.returncode == 0 and res.stdout.strip():
return res.stdout.strip().split("\n")[0]
except Exception:
pass
return "Unknown_GPU"
def round_to_multiple_of_32(val):
return max(32, (int(val) // 32) * 32)
def round_to_multiple_of_16(val):
return max(16, (int(val) // 16) * 16)
def run_single_trial(python_bin, worker_script, config_path, batch_size, num_workers, warmup_steps, measure_steps, output_dir):
trial_json = os.path.join(output_dir, f"trial_b{batch_size}_w{num_workers}.json")
if os.path.exists(trial_json):
try:
os.remove(trial_json)
except Exception:
pass
cmd = [
python_bin,
worker_script,
"-c", config_path,
"-b", str(batch_size),
"-w", str(num_workers),
"--warmup_steps", str(warmup_steps),
"--measure_steps", str(measure_steps),
"-o", trial_json
]
print(f"\n>>> [Trial] Launching subprocess: Batch={batch_size}, Workers={num_workers} (Warmup={warmup_steps}, Measure={measure_steps})", flush=True)
t0 = time.time()
res = subprocess.run(cmd)
elapsed = time.time() - t0
if os.path.exists(trial_json):
try:
with open(trial_json, 'r', encoding='utf-8') as f:
data = json.load(f)
data["elapsed_wall_time"] = round(elapsed, 2)
return data
except Exception as e:
print(f"Failed to read trial json: {e}")
# Fallback if trial json was not written
status = "OOM" if res.returncode in (137, -9) else "CRASH"
return {
"batch_size": batch_size,
"num_workers": num_workers,
"warmup_steps": warmup_steps,
"measure_steps": measure_steps,
"status": status,
"ips": 0.0,
"avg_reader_cost": 0.0,
"avg_batch_cost": 0.0,
"reader_ratio": 0.0,
"peak_vram_mb": 0.0,
"total_vram_mb": 0.0,
"vram_pct": 0.0,
"avg_gpu_util": 0.0,
"avg_cpu_pct": 0.0,
"error": f"Process exited with code {res.returncode}",
"elapsed_wall_time": round(elapsed, 2)
}
def compute_economics(trial, cost_per_hour):
ips = trial.get("ips", 0.0)
if ips > 0:
est_hours = TOTAL_TRAIN_SAMPLES / (ips * 3600.0)
est_cost = est_hours * cost_per_hour
ips_per_dollar = ips / cost_per_hour
else:
est_hours = 0.0
est_cost = 0.0
ips_per_dollar = 0.0
trial["est_40_epoch_hours"] = round(est_hours, 2)
trial["est_40_epoch_cost"] = round(est_cost, 2)
trial["ips_per_dollar"] = round(ips_per_dollar, 2)
trial["cost_per_hour"] = cost_per_hour
return trial
def run_benchmark_suite(config_path, gpu_name=None, cost_per_hour=None, python_bin=None, warmup_steps=100, measure_steps=300, output_dir="benchmark_results"):
os.makedirs(output_dir, exist_ok=True)
if not python_bin:
python_bin = sys.executable
script_dir = os.path.dirname(os.path.abspath(__file__))
worker_script = os.path.join(script_dir, "benchmark_worker.py")
if not os.path.exists(worker_script):
raise FileNotFoundError(f"Worker script not found: {worker_script}")
detected_gpu = detect_gpu_name()
if not gpu_name:
gpu_name = detected_gpu
if cost_per_hour is None:
cost_per_hour = 2.19
for k, v in PRICING_TABLE.items():
if k.lower() in gpu_name.lower():
cost_per_hour = v
break
cpu_count = os.cpu_count() or 8
print("=" * 80)
print(f"🚀 PADDLEOCR AUTOMATED HARDWARE BENCHMARK SUITE")
print(f"GPU: {gpu_name} (Detected: {detected_gpu}) | Cost: ${cost_per_hour:.2f}/h")
print(f"CPU Cores: {cpu_count} | Python: {python_bin}")
print(f"Config: {config_path}")
print(f"Warmup: {warmup_steps} steps | Measure: {measure_steps} steps")
print("=" * 80, flush=True)
all_trials = []
# ---------------------------------------------------------
# PHASE 1: FIND OPTIMAL NUM_WORKERS AT BASE BATCH
# ---------------------------------------------------------
base_batch = 128
initial_oom_batch = None
print(f"\nTesting initial base batch {base_batch} with 4 workers...")
test_init = run_single_trial(
python_bin, worker_script, config_path,
batch_size=base_batch, num_workers=4,
warmup_steps=warmup_steps, measure_steps=measure_steps,
output_dir=output_dir
)
test_init = compute_economics(test_init, cost_per_hour)
all_trials.append(test_init)
if test_init["status"] == "OOM":
initial_oom_batch = base_batch
print(f"⚠️ Base batch {base_batch} encountered OOM on {gpu_name}! Stepping down to locate viable batch...")
found_base = False
for fallback_b in [96, 64, 48, 32]:
print(f"Testing fallback batch {fallback_b}...")
t_fb = run_single_trial(
python_bin, worker_script, config_path,
batch_size=fallback_b, num_workers=4,
warmup_steps=warmup_steps, measure_steps=measure_steps,
output_dir=output_dir
)
t_fb = compute_economics(t_fb, cost_per_hour)
all_trials.append(t_fb)
if t_fb["status"] == "SUCCESS":
base_batch = fallback_b
found_base = True
print(f"✅ Viable base batch found: {base_batch} (VRAM: {t_fb['vram_pct']:.1f}%)")
break
if not found_base:
raise RuntimeError(f"Unable to find viable batch size even at batch 32 on {gpu_name}")
print("\n" + "=" * 60)
print(f"📍 [PHASE 1/3] SWEEPING NUM_WORKERS AT BATCH SIZE {base_batch}")
print("=" * 60, flush=True)
candidate_workers = [4, 8, 12, 16, 24]
max_reasonable_workers = min(24, max(8, cpu_count * 2))
candidate_workers = [w for w in candidate_workers if w <= max_reasonable_workers]
if 4 not in candidate_workers:
candidate_workers.insert(0, 4)
best_worker = 4
best_worker_ips = 0.0
worker_trials = []
for w in candidate_workers:
# Check if already tested
existing = [t for t in all_trials if t["batch_size"] == base_batch and t["num_workers"] == w]
if existing:
trial = existing[0]
else:
trial = run_single_trial(
python_bin, worker_script, config_path,
batch_size=base_batch, num_workers=w,
warmup_steps=warmup_steps, measure_steps=measure_steps,
output_dir=output_dir
)
trial = compute_economics(trial, cost_per_hour)
all_trials.append(trial)
worker_trials.append(trial)
if trial["status"] == "SUCCESS":
print(f" -> Workers={w:2d} | IPS={trial['ips']:6.1f} | Reader={trial['avg_reader_cost']:.4f}s | Batch={trial['avg_batch_cost']:.4f}s | ReaderRatio={trial['reader_ratio']:.2f}")
if trial["ips"] > best_worker_ips:
best_worker_ips = trial["ips"]
best_worker = w
else:
print(f" -> Workers={w:2d} | FAILED ({trial['status']}): {trial.get('error')}")
print(f"\n✅ Optimal num_workers selected: {best_worker} (IPS: {best_worker_ips:.1f} samples/s)")
# ---------------------------------------------------------
# PHASE 2: BATCH SIZE SCALING
# ---------------------------------------------------------
print("\n" + "=" * 60)
print(f"📍 [PHASE 2/3] BATCH SIZE PROGRESSION (WORKERS = {best_worker})")
print("=" * 60, flush=True)
if base_batch < 128:
# Fine-grained progression for smaller GPUs
batch_progression = [base_batch, 80, 96, 112, 128, 144, 160, 192, 224, 256]
else:
batch_progression = [128, 160, 192, 224, 256, 320, 384, 512, 768, 1024]
# Filter only batches >= base_batch
batch_progression = sorted(list(set([b for b in batch_progression if b >= base_batch])))
batch_trials = []
last_safe_batch = base_batch
last_safe_trial = next((t for t in worker_trials if t["batch_size"] == base_batch and t["num_workers"] == best_worker and t["status"] == "SUCCESS"), None)
oom_batch = initial_oom_batch
best_batch = base_batch
best_batch_ips = best_worker_ips
diminishing_returns_detected = False
for b in batch_progression:
# If batch 128 was already tested in Phase 1 with the same worker, reuse result
existing = [t for t in worker_trials if t["batch_size"] == b and t["num_workers"] == best_worker]
if existing:
trial = existing[0]
else:
trial = run_single_trial(
python_bin, worker_script, config_path,
batch_size=b, num_workers=best_worker,
warmup_steps=warmup_steps, measure_steps=measure_steps,
output_dir=output_dir
)
trial = compute_economics(trial, cost_per_hour)
all_trials.append(trial)
batch_trials.append(trial)
if trial["status"] == "SUCCESS":
cur_ips = trial["ips"]
vram_pct = trial["vram_pct"]
reader_ratio = trial["reader_ratio"]
# Calculate improvement over previous successful batch
delta_ips_pct = 0.0
if last_safe_trial and last_safe_trial["ips"] > 0:
delta_ips_pct = ((cur_ips - last_safe_trial["ips"]) / last_safe_trial["ips"]) * 100
print(f" -> Batch={b:4d} | IPS={cur_ips:6.1f} ({delta_ips_pct:+5.1f}%) | VRAM={trial['peak_vram_mb']:5.0f}MB ({vram_pct:4.1f}%) | ReaderRatio={reader_ratio:.2f} | Est: {trial['est_40_epoch_hours']:.1f}h (${trial['est_40_epoch_cost']:.2f})")
# Check for best throughput batch
if cur_ips > best_batch_ips:
best_batch_ips = cur_ips
best_batch = b
# Check diminishing returns: if gain < 3% compared to previous batch
if last_safe_trial and delta_ips_pct < 3.0:
print(f" ⚠️ Diminishing returns detected at Batch={b} (Throughput gain {delta_ips_pct:.2f}% < 3.0%). Sweet spot identified.")
diminishing_returns_detected = True
last_safe_batch = b
last_safe_trial = trial
# Check if VRAM exceeds 92% (danger zone)
if vram_pct >= 92.0:
print(f" ⚠️ VRAM usage reached {vram_pct:.1f}% (>= 92%). Halting batch scaling to avoid sudden hard OOM.")
break
# If diminishing returns detected and VRAM is already high, stop scaling further
if diminishing_returns_detected and vram_pct >= 85.0:
print(f" 🏁 Stopping batch progression: Throughput saturated and VRAM comfortably utilized.")
break
elif trial["status"] == "OOM":
print(f" 💥 OOM encountered at Batch={b}!")
oom_batch = b
break
else:
print(f" ❌ Trial failed at Batch={b} with status {trial['status']}: {trial.get('error')}")
break
# ---------------------------------------------------------
# PHASE 3: BINARY SEARCH FOR EXACT MAX SAFE BATCH
# ---------------------------------------------------------
max_safe_batch = last_safe_batch
if oom_batch is not None and last_safe_batch is not None:
print("\n" + "=" * 60)
print(f"📍 [PHASE 3/3] BINARY SEARCH BETWEEN {last_safe_batch} (SAFE) AND {oom_batch} (OOM)")
print("=" * 60, flush=True)
low = last_safe_batch
high = oom_batch
while (high - low) > 16:
mid = round_to_multiple_of_16((low + high) / 2)
if mid == low or mid == high:
break
print(f"\n[Binary Search] Testing midpoint Batch={mid} (range: [{low}, {high}])...")
trial = run_single_trial(
python_bin, worker_script, config_path,
batch_size=mid, num_workers=best_worker,
warmup_steps=warmup_steps, measure_steps=measure_steps,
output_dir=output_dir
)
trial = compute_economics(trial, cost_per_hour)
all_trials.append(trial)
if trial["status"] == "SUCCESS":
print(f" -> Batch={mid} succeeded! IPS={trial['ips']:.1f}, VRAM={trial['vram_pct']:.1f}%")
low = mid
if trial["ips"] > best_batch_ips and not diminishing_returns_detected:
best_batch_ips = trial["ips"]
best_batch = mid
else:
print(f" -> Batch={mid} failed with {trial['status']}.")
high = mid
max_safe_batch = low
print(f"\n🎯 Binary Search Complete: Max Safe Batch = {max_safe_batch}")
# ---------------------------------------------------------
# PHASE 4: BOTTLENECK ANALYSIS & REPORT GENERATION
# ---------------------------------------------------------
best_trial = next((t for t in all_trials if t["batch_size"] == best_batch and t["num_workers"] == best_worker and t["status"] == "SUCCESS"), last_safe_trial)
# Identify primary bottleneck
bottleneck = "Balanced"
if best_trial:
if best_trial["vram_pct"] >= 90.0:
bottleneck = "VRAM Capacity Limited"
elif best_trial["reader_ratio"] >= 0.40:
bottleneck = "DataLoader / CPU Bound"
elif best_trial["avg_gpu_util"] >= 80.0 and best_trial["reader_ratio"] < 0.20:
bottleneck = "GPU Compute Saturated (Optimal)"
elif best_trial["avg_cpu_pct"] >= 85.0:
bottleneck = "Host CPU Bound"
else:
bottleneck = "Memory Bandwidth / Pipeline Latency"
summary = {
"gpu_name": gpu_name,
"cost_per_hour": cost_per_hour,
"optimal_workers": best_worker,
"best_batch": best_batch,
"max_safe_batch": max_safe_batch,
"best_ips": best_trial["ips"] if best_trial else 0.0,
"primary_bottleneck": bottleneck,
"best_trial": best_trial,
"all_trials": all_trials
}
# Save JSON summary
summary_file = os.path.join(output_dir, f"benchmark_{gpu_name.replace(' ', '_')}.json")
with open(summary_file, 'w', encoding='utf-8') as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
# Generate Markdown Report
md_report = generate_markdown_report(summary)
md_file = os.path.join(output_dir, f"benchmark_{gpu_name.replace(' ', '_')}.md")
with open(md_file, 'w', encoding='utf-8') as f:
f.write(md_report)
print("\n" + "=" * 80)
print("📊 BENCHMARK REPORT COMPLETED")
print(f"JSON Output: {summary_file}")
print(f"MD Output: {md_file}")
print("=" * 80)
print(md_report)
return summary
def generate_markdown_report(summary):
gpu = summary["gpu_name"]
cost = summary["cost_per_hour"]
best_b = summary["best_batch"]
max_b = summary["max_safe_batch"]
workers = summary["optimal_workers"]
bottleneck = summary["primary_bottleneck"]
trials = summary["all_trials"]
lines = []
lines.append(f"# Benchmark & Hardware Limit Analysis: {gpu}")
lines.append(f"**Cluster / GPU**: `{gpu}` | **Pricing**: `${cost:.2f}/h` | **Primary Bottleneck**: `{bottleneck}`\n")
lines.append("## 1. Kết Quả Đo Lường Chi Tiết (Full Measurement Trials)")
lines.append("| Batch | Workers | Status | IPS (mẫu/s) | GPU Util % | VRAM (MB) | VRAM % | Reader Cost | Batch Cost | Reader Ratio | Est 40 Epoch | Est Cost | IPS/$ |")
lines.append("|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|")
for t in trials:
b = t["batch_size"]
w = t["num_workers"]
st = t["status"]
if st == "SUCCESS":
ips = f"{t['ips']:.1f}"
gpu_u = f"{t['avg_gpu_util']:.0f}%"
vram_mb = f"{t['peak_vram_mb']:.0f}"
vram_p = f"{t['vram_pct']:.1f}%"
r_cost = f"{t['avg_reader_cost']:.4f}s"
b_cost = f"{t['avg_batch_cost']:.4f}s"
r_ratio = f"{t['reader_ratio']:.2f}"
est_h = f"{t['est_40_epoch_hours']:.2f}h"
est_c = f"${t['est_40_epoch_cost']:.2f}"
ips_d = f"{t['ips_per_dollar']:.1f}"
else:
ips = "-"
gpu_u = "-"
vram_mb = "-"
vram_p = "-"
r_cost = "-"
b_cost = "-"
r_ratio = "-"
est_h = "-"
est_c = "-"
ips_d = "-"
# Highlight best and max
tag = ""
if b == best_b and w == workers and st == "SUCCESS":
tag = " 🌟 *(Best)*"
elif b == max_b and w == workers and st == "SUCCESS":
tag = " 🛡️ *(Max Safe)*"
elif st == "OOM":
tag = " 💥 *(OOM)*"
lines.append(f"| {b}{tag} | {w} | {st} | {ips} | {gpu_u} | {vram_mb} | {vram_p} | {r_cost} | {b_cost} | {r_ratio} | {est_h} | {est_c} | {ips_d} |")
lines.append("\n## 2. Kết Luận & Đề Xuất Cấu Hình")
best_t = summary.get("best_trial")
if best_t:
lines.append(f"- **Max Batch Chạy Được (Hardware Limit)**: `{max_b}` (ngưỡng tối đa trước khi tràn VRAM).")
lines.append(f"- **Best Batch Nên Dùng (Sweet Spot)**: `{best_b}` (đạt throughput `{best_t['ips']:.1f}` mẫu/s, khai thác `{best_t['vram_pct']:.1f}%` VRAM).")
lines.append(f"- **Workers Tối Ưu**: `{workers}` (giữ `reader_ratio = {best_t['reader_ratio']:.2f}`, đảm bảo GPU không bị đói dữ liệu).")
lines.append(f"- **Điểm Nghẽn Chính (Bottleneck)**: `{bottleneck}`.")
lines.append(f"- **Thời Gian Huấn Luyện Full 40 Epoch (10M samples)**: `{best_t['est_40_epoch_hours']:.2f} giờ` (~`{best_t['est_40_epoch_hours']*60:.0f} phút`).")
lines.append(f"- **Chi Phí Ước Tính Full 40 Epoch**: `${best_t['est_40_epoch_cost']:.2f}`.")
lines.append(f"- **Hiệu Suất Kinh Tế (Throughput / $)**: `{best_t['ips_per_dollar']:.1f} samples / $`.")
return "\n".join(lines)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Automated PaddleOCR Benchmark Suite")
parser.add_argument("-c", "--config_path", type=str, required=True, help="Path to YAML config")
parser.add_argument("--gpu_name", type=str, default=None, help="GPU identifier name")
parser.add_argument("--cost_per_hour", type=float, default=None, help="Hourly cost in USD")
parser.add_argument("--python_bin", type=str, default=None, help="Path to Python interpreter")
parser.add_argument("--warmup_steps", type=int, default=100, help="Warmup steps")
parser.add_argument("--measure_steps", type=int, default=300, help="Measure steps")
parser.add_argument("-o", "--output_dir", type=str, default="benchmark_results", help="Directory to save results")
args = parser.parse_args()
run_benchmark_suite(
config_path=args.config_path,
gpu_name=args.gpu_name,
cost_per_hour=args.cost_per_hour,
python_bin=args.python_bin,
warmup_steps=args.warmup_steps,
measure_steps=args.measure_steps,
output_dir=args.output_dir
)