Files
rustytorch/examples/pinn_mre_helmholtz/python/benchmark_runner.py
T
2026-03-04 00:08:42 +00:00

616 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Benchmark runner for PyTorch PINN - mirrors Rust benchmark structure.
This script benchmarks the PyTorch PINN implementation to compare with RustyTorch++.
Usage:
python benchmark_runner.py [--pytorch-pinn-path PATH] [--output OUTPUT]
The script expects a PyTorch PINN implementation with the following interface:
- Config class with physics/network/training parameters
- Mre1DPinnSolver class with:
- forward() method for network evaluation
- compute_pde_residual() method for PDE computation
- training_step() method for single training iteration
- train() method for full training loop
If no --pytorch-pinn-path is provided, a minimal reference implementation is used.
"""
import json
import time
import gc
import argparse
import sys
from dataclasses import dataclass, asdict, field
from typing import List, Dict, Optional, Tuple
from pathlib import Path
# Optional imports with fallbacks
try:
import tracemalloc
HAS_TRACEMALLOC = True
except ImportError:
HAS_TRACEMALLOC = False
try:
import numpy as np
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
print("Warning: numpy not available, some benchmarks will be skipped")
try:
import torch
import torch.nn as nn
HAS_TORCH = True
except ImportError:
HAS_TORCH = False
print("Warning: torch not available, PyTorch benchmarks will be skipped")
@dataclass
class BenchmarkResult:
"""Result of a single benchmark."""
name: str
n_points: int
mean_time_ms: float
std_time_ms: float
min_time_ms: float
max_time_ms: float
peak_memory_mb: float
iterations: int
throughput_elements_per_sec: float = 0.0
@dataclass
class BenchmarkSuite:
"""Collection of benchmark results."""
framework: str
device: str
version: str
benchmarks: List[BenchmarkResult] = field(default_factory=list)
def warmup(func, n_warmup: int = 10):
"""Run warmup iterations."""
for _ in range(n_warmup):
_ = func()
def measure_time(func, n_iterations: int = 100) -> Tuple[List[float], float]:
"""Measure execution time and peak memory."""
times = []
peak_memory = 0.0
if HAS_TRACEMALLOC:
tracemalloc.start()
if HAS_TORCH and torch.cuda.is_available():
torch.cuda.synchronize()
for _ in range(n_iterations):
start = time.perf_counter()
_ = func()
if HAS_TORCH and torch.cuda.is_available():
torch.cuda.synchronize()
times.append((time.perf_counter() - start) * 1000)
if HAS_TRACEMALLOC:
_, peak_memory = tracemalloc.get_traced_memory()
tracemalloc.stop()
peak_memory = peak_memory / 1024 / 1024 # Convert to MB
return times, peak_memory
def create_benchmark_result(
name: str,
n_points: int,
times: List[float],
peak_memory: float,
n_iterations: int
) -> BenchmarkResult:
"""Create a BenchmarkResult from timing data."""
import statistics
mean_time = statistics.mean(times)
return BenchmarkResult(
name=name,
n_points=n_points,
mean_time_ms=mean_time,
std_time_ms=statistics.stdev(times) if len(times) > 1 else 0.0,
min_time_ms=min(times),
max_time_ms=max(times),
peak_memory_mb=peak_memory,
iterations=n_iterations,
throughput_elements_per_sec=(n_points / mean_time * 1000) if mean_time > 0 else 0.0
)
# =============================================================================
# MINIMAL PYTORCH PINN REFERENCE IMPLEMENTATION
# =============================================================================
if HAS_TORCH and HAS_NUMPY:
class MinimalConfig:
"""Minimal configuration matching Rust Config."""
def __init__(self, n_data: int = 200):
# Physics
self.rho = 1040.0
self.freq = 50.0
self.l = 0.1
self.u0 = 1e-6
self.g_prime_true = 3000.0
self.g_double_true = 1500.0
# Grid
self.n_data = n_data
self.n_pde = n_data
# Network
self.u_layers = 4
self.u_hidden = 64
self.u_ff_dim = 64
self.u_ff_scale = 10.0
# Training
self.lr = 1e-3
self.epochs = 50000
self.scheduler_patience = 500
self.scheduler_factor = 0.5
# Loss
self.data_weight = 1.0
self.pde_weight = 1e-6
def calculate_k(cfg: MinimalConfig) -> complex:
"""Calculate complex wave number."""
omega = 2.0 * np.pi * cfg.freq
G_complex = cfg.g_prime_true + 1j * cfg.g_double_true
k = np.sqrt(cfg.rho * omega**2 / G_complex)
if k.imag < 0:
k = -k
return k
def synthesize_displacement(cfg: MinimalConfig) -> Tuple[np.ndarray, np.ndarray, np.ndarray, complex]:
"""Generate synthetic displacement data."""
k = calculate_k(cfg)
x = np.linspace(0.0, cfg.l, cfg.n_data)
u_complex = cfg.u0 * np.exp(1j * k * x)
return x, u_complex.real, u_complex.imag, k
class LffnUNet1D(nn.Module):
"""Learnable Fourier Feature Network with MLP."""
def __init__(self, cfg: MinimalConfig):
super().__init__()
self.B_learnable = nn.Parameter(
torch.randn(1, cfg.u_ff_dim) * cfg.u_ff_scale
)
layers = []
dim = cfg.u_ff_dim * 2
for _ in range(cfg.u_layers):
layers.append(nn.Linear(dim, cfg.u_hidden))
layers.append(nn.Tanh())
dim = cfg.u_hidden
layers.append(nn.Linear(dim, 2))
self.net = nn.Sequential(*layers)
def forward(self, x_norm: torch.Tensor) -> torch.Tensor:
y = 2.0 * np.pi * x_norm @ self.B_learnable
sin_feat = torch.sin(y)
cos_feat = torch.cos(y)
feat = torch.cat([sin_feat, cos_feat], dim=-1)
return self.net(feat)
class MinimalPinnSolver:
"""Minimal PINN solver for benchmarking."""
def __init__(self, cfg: MinimalConfig, device: str = 'cpu'):
self.cfg = cfg
self.device = torch.device(device)
# Physics
self.omega = 2.0 * np.pi * cfg.freq
self.k_true = calculate_k(cfg)
# Generate data
x, u_r, u_i, _ = synthesize_displacement(cfg)
u_scale = np.max(np.sqrt(u_r**2 + u_i**2)) + 1e-16
self.u_scale = u_scale
# Normalize and convert to tensors
x_norm = torch.tensor(x / cfg.l, dtype=torch.float32, device=self.device).reshape(-1, 1)
u_data = torch.tensor(
np.stack([u_r / u_scale, u_i / u_scale], axis=1),
dtype=torch.float32, device=self.device
)
self.x_data = x_norm.requires_grad_(True)
self.u_data_target = u_data
# Network
self.u_net = LffnUNet1D(cfg).to(self.device)
# Optimizer
self.optimizer = torch.optim.Adam(self.u_net.parameters(), lr=cfg.lr)
self.scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
self.optimizer, mode='min', factor=cfg.scheduler_factor,
patience=cfg.scheduler_patience
)
def forward(self) -> torch.Tensor:
"""Forward pass through network."""
return self.u_net(self.x_data)
def compute_data_loss(self, u_pred: torch.Tensor) -> torch.Tensor:
"""Compute MSE data loss."""
return torch.mean((u_pred - self.u_data_target)**2)
def compute_pde_residual(self) -> Tuple[float, float]:
"""Compute PDE residual using analytical derivatives."""
x = np.linspace(0.0, self.cfg.l, self.cfg.n_data)
u_complex = self.cfg.u0 * np.exp(1j * self.k_true * x)
# Analytical d²u/dx²
d2u = -(self.k_true**2) * u_complex
# Helmholtz: d²u/dx² + k²u = 0
k_sq = self.k_true**2
residual = d2u + k_sq * u_complex
mse_rr = np.mean(residual.real**2)
mse_ri = np.mean(residual.imag**2)
return mse_rr, mse_ri
def training_step(self) -> Tuple[float, float, float]:
"""Single training step."""
self.optimizer.zero_grad()
u_pred = self.forward()
loss_data = self.compute_data_loss(u_pred)
mse_rr, mse_ri = self.compute_pde_residual()
loss_pde = mse_rr + mse_ri
total_loss = self.cfg.data_weight * loss_data.item() + self.cfg.pde_weight * loss_pde
loss_data.backward()
self.optimizer.step()
self.scheduler.step(loss_data)
return loss_data.item(), loss_pde, total_loss
def train(self, epochs: int = None) -> float:
"""Full training loop."""
epochs = epochs or self.cfg.epochs
best_loss = float('inf')
for _ in range(epochs):
_, _, total_loss = self.training_step()
if total_loss < best_loss:
best_loss = total_loss
return best_loss
# =============================================================================
# BENCHMARK FUNCTIONS
# =============================================================================
def benchmark_forward_pass(suite: BenchmarkSuite, point_sizes: List[int] = [200, 1000, 10000], forced_device: str = "auto"):
"""Benchmark forward pass (network evaluation)."""
if not HAS_TORCH:
print("Skipping forward_pass benchmark: torch not available")
return
if forced_device == "auto":
device = 'cuda' if torch.cuda.is_available() else 'cpu'
elif forced_device == "cuda" and torch.cuda.is_available():
device = 'cuda'
else:
device = 'cpu'
for n_points in point_sizes:
print(f" forward_pass n={n_points}...", end=" ", flush=True)
cfg = MinimalConfig(n_data=n_points)
solver = MinimalPinnSolver(cfg, device=device)
warmup(solver.forward)
times, peak_memory = measure_time(solver.forward, n_iterations=100)
result = create_benchmark_result(
name="forward_pass/lffn_mlp",
n_points=n_points,
times=times,
peak_memory=peak_memory,
n_iterations=100
)
suite.benchmarks.append(result)
print(f"{result.mean_time_ms:.3f}ms")
del solver
gc.collect()
if HAS_TORCH and torch.cuda.is_available():
torch.cuda.empty_cache()
def benchmark_pde_residual(suite: BenchmarkSuite, point_sizes: List[int] = [200, 1000, 10000], forced_device: str = "auto"):
"""Benchmark PDE residual computation."""
if not HAS_TORCH:
print("Skipping pde_residual benchmark: torch not available")
return
if forced_device == "auto":
device = 'cuda' if torch.cuda.is_available() else 'cpu'
elif forced_device == "cuda" and torch.cuda.is_available():
device = 'cuda'
else:
device = 'cpu'
for n_points in point_sizes:
print(f" pde_residual n={n_points}...", end=" ", flush=True)
cfg = MinimalConfig(n_data=n_points)
solver = MinimalPinnSolver(cfg, device=device)
warmup(solver.compute_pde_residual)
times, peak_memory = measure_time(solver.compute_pde_residual, n_iterations=100)
result = create_benchmark_result(
name="pde_residual/helmholtz",
n_points=n_points,
times=times,
peak_memory=peak_memory,
n_iterations=100
)
suite.benchmarks.append(result)
print(f"{result.mean_time_ms:.3f}ms")
del solver
gc.collect()
def benchmark_training_step(suite: BenchmarkSuite, point_sizes: List[int] = [200, 1000], forced_device: str = "auto"):
"""Benchmark single training step."""
if not HAS_TORCH:
print("Skipping training_step benchmark: torch not available")
return
if forced_device == "auto":
device = 'cuda' if torch.cuda.is_available() else 'cpu'
elif forced_device == "cuda" and torch.cuda.is_available():
device = 'cuda'
else:
device = 'cpu'
for n_points in point_sizes:
print(f" training_step n={n_points}...", end=" ", flush=True)
cfg = MinimalConfig(n_data=n_points)
solver = MinimalPinnSolver(cfg, device=device)
warmup(solver.training_step, n_warmup=5)
times, peak_memory = measure_time(solver.training_step, n_iterations=50)
result = create_benchmark_result(
name="training_step/single_step",
n_points=n_points,
times=times,
peak_memory=peak_memory,
n_iterations=50
)
suite.benchmarks.append(result)
print(f"{result.mean_time_ms:.3f}ms")
del solver
gc.collect()
if HAS_TORCH and torch.cuda.is_available():
torch.cuda.empty_cache()
def benchmark_data_generation(suite: BenchmarkSuite, point_sizes: List[int] = [200, 1000, 10000, 100000]):
"""Benchmark data generation."""
if not HAS_NUMPY:
print("Skipping data_generation benchmark: numpy not available")
return
for n_points in point_sizes:
print(f" data_generation n={n_points}...", end=" ", flush=True)
cfg = MinimalConfig(n_data=n_points)
def gen_data():
return synthesize_displacement(cfg)
warmup(gen_data)
times, peak_memory = measure_time(gen_data, n_iterations=100)
result = create_benchmark_result(
name="data_generation/synthesize_displacement",
n_points=n_points,
times=times,
peak_memory=peak_memory,
n_iterations=100
)
suite.benchmarks.append(result)
print(f"{result.mean_time_ms:.3f}ms")
def benchmark_wave_number(suite: BenchmarkSuite):
"""Benchmark wave number calculation."""
if not HAS_NUMPY:
print("Skipping wave_number benchmark: numpy not available")
return
print(" calculate_k...", end=" ", flush=True)
cfg = MinimalConfig()
def calc_k():
return calculate_k(cfg)
warmup(calc_k)
times, peak_memory = measure_time(calc_k, n_iterations=1000)
result = create_benchmark_result(
name="wave_number/calculate_k",
n_points=1,
times=times,
peak_memory=peak_memory,
n_iterations=1000
)
suite.benchmarks.append(result)
print(f"{result.mean_time_ms:.4f}ms")
def benchmark_training_100_epochs(suite: BenchmarkSuite, point_sizes: List[int] = [200], forced_device: str = "auto"):
"""Benchmark 100 epochs of training."""
if not HAS_TORCH:
print("Skipping training_100_epochs benchmark: torch not available")
return
if forced_device == "auto":
device = 'cuda' if torch.cuda.is_available() else 'cpu'
elif forced_device == "cuda" and torch.cuda.is_available():
device = 'cuda'
else:
device = 'cpu'
for n_points in point_sizes:
print(f" training_100_epochs n={n_points}...", end=" ", flush=True)
def run_training():
cfg = MinimalConfig(n_data=n_points)
solver = MinimalPinnSolver(cfg, device=device)
return solver.train(epochs=100)
# Only 5 iterations since this is slow
times, peak_memory = measure_time(run_training, n_iterations=5)
result = create_benchmark_result(
name="training_100_epochs/train_100",
n_points=n_points,
times=times,
peak_memory=peak_memory,
n_iterations=5
)
suite.benchmarks.append(result)
print(f"{result.mean_time_ms:.1f}ms")
gc.collect()
if HAS_TORCH and torch.cuda.is_available():
torch.cuda.empty_cache()
def run_all_benchmarks(point_sizes: Optional[List[int]] = None, forced_device: str = "auto") -> BenchmarkSuite:
"""Run complete benchmark suite.
Args:
point_sizes: List of point sizes to benchmark
forced_device: Device to use - "cpu", "cuda", or "auto" (detect)
"""
point_sizes = point_sizes or [200, 1000, 10000]
# Determine device based on forced_device argument
if forced_device == "auto":
if HAS_TORCH and torch.cuda.is_available():
device = f"cuda:{torch.cuda.get_device_name(0)}"
else:
device = "cpu"
elif forced_device == "cuda":
if HAS_TORCH and torch.cuda.is_available():
device = f"cuda:{torch.cuda.get_device_name(0)}"
else:
print("Warning: CUDA requested but not available, falling back to CPU")
device = "cpu"
else:
device = "cpu"
version = "unknown"
if HAS_TORCH:
version = torch.__version__
suite = BenchmarkSuite(
framework="pytorch",
device=device,
version=version
)
print(f"\n=== PyTorch PINN Benchmarks ===")
print(f"Device: {device}")
print(f"Version: {version}")
print()
print("Running benchmarks:")
benchmark_wave_number(suite)
benchmark_data_generation(suite, point_sizes + [100000])
benchmark_forward_pass(suite, point_sizes, forced_device)
benchmark_pde_residual(suite, point_sizes, forced_device)
benchmark_training_step(suite, [200, 1000], forced_device)
benchmark_training_100_epochs(suite, [200], forced_device)
return suite
def main():
parser = argparse.ArgumentParser(description="PyTorch PINN Benchmark Runner")
parser.add_argument(
"--pytorch-pinn-path",
type=str,
default=None,
help="Path to PyTorch PINN implementation (uses minimal reference if not provided)"
)
parser.add_argument(
"--output",
type=str,
default="python_results.json",
help="Output JSON file for results"
)
parser.add_argument(
"--point-sizes",
type=str,
default="200,1000,10000",
help="Comma-separated list of point sizes to benchmark"
)
parser.add_argument(
"--device",
type=str,
choices=["cpu", "cuda", "auto"],
default="auto",
help="Device to use: cpu, cuda, or auto (detect)"
)
args = parser.parse_args()
point_sizes = [int(x) for x in args.point_sizes.split(",")]
forced_device = args.device
if args.pytorch_pinn_path:
print(f"Loading custom PyTorch PINN from: {args.pytorch_pinn_path}")
# Import the reference implementation if available
import importlib.util
spec = importlib.util.spec_from_file_location("pytorch_pinn", args.pytorch_pinn_path)
if spec and spec.loader:
pytorch_pinn = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pytorch_pinn)
print(f"Loaded custom PINN from: {args.pytorch_pinn_path}")
# Use the loaded implementation for benchmarks
global MinimalConfig, MinimalPinnSolver, LffnUNet1D
MinimalConfig = pytorch_pinn.Config
# Note: Would need adapter for Mre1DFixedGPinn -> MinimalPinnSolver interface
else:
print("Failed to load custom PINN, using minimal reference")
suite = run_all_benchmarks(point_sizes, forced_device)
# Save results
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(asdict(suite), f, indent=2)
print(f"\nResults saved to: {output_path}")
# Print summary table
print("\n" + "=" * 70)
print("BENCHMARK SUMMARY")
print("=" * 70)
print(f"{'Benchmark':<40} {'Points':>8} {'Mean (ms)':>12} {'Std':>10}")
print("-" * 70)
for result in suite.benchmarks:
print(f"{result.name:<40} {result.n_points:>8} {result.mean_time_ms:>12.3f} {result.std_time_ms:>10.3f}")
print("=" * 70)
if __name__ == "__main__":
main()