#!/usr/bin/env python3 """ CUDASlicer Benchmark Suite ========================== Comprehensive performance benchmark comparing: 1. Baseline: PrusaSlicer CLI 2. CUDASlicer Matrix Configurations: - Backends: CUDA (GPU) vs CPU (Multi-threaded) - Interpolation: Sub-Pixel LERP vs No LERP (Discrete Marching Squares) - Grid Resolutions: 0.10 mm vs 0.05 mm Pitch """ from __future__ import annotations import argparse import os import shutil import struct import subprocess import sys import time from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple import numpy as np # Ensure local cudaslicer python package is discoverable REPO_ROOT = Path(__file__).resolve().parent PYTHON_PKG_DIR = REPO_ROOT / "python" if PYTHON_PKG_DIR.exists() and str(PYTHON_PKG_DIR) not in sys.path: sys.path.insert(0, str(PYTHON_PKG_DIR)) try: import cudaslicer except ImportError: cudaslicer = None # ============================================================================== # Fast Binary / ASCII STL Loader # ============================================================================== def load_stl(file_path: str | Path) -> np.ndarray: """ Load an STL file into a NumPy array of shape (N, 3, 3) representing 3D triangles. Supports both Binary and ASCII STL files without external dependencies. """ p = Path(file_path) if not p.exists(): raise FileNotFoundError(f"STL file not found: {p}") file_size = p.stat().st_size if file_size < 84: raise ValueError(f"File {p} is too small to be a valid STL.") with open(p, "rb") as f: header = f.read(80) num_triangles_bytes = f.read(4) num_triangles = struct.unpack("= 4: vertices.append([float(parts[1]), float(parts[2]), float(parts[3])]) if len(vertices) % 3 != 0 or len(vertices) == 0: raise ValueError(f"Invalid or corrupted STL file: {p}") arr = np.array(vertices, dtype=np.float32) return arr.reshape(-1, 3, 3) # ============================================================================== # PrusaSlicer Baseline Runner # ============================================================================== def find_prusaslicer_bin(custom_path: Optional[str] = None) -> Optional[str]: """Find a functional PrusaSlicer executable.""" candidates = [ custom_path, str(REPO_ROOT / "tests_and_tools" / "prusa_binaries" / "squashfs-root" / "usr" / "bin" / "prusa-slicer"), str(REPO_ROOT / "tests_and_tools" / "prusa_binaries" / "prusa-slicer.AppImage"), str(REPO_ROOT / "bin" / "prusa-slicer"), "prusa-slicer", "prusa-slicer-console", ] for cand in candidates: if cand and (os.path.isfile(cand) or shutil.which(cand)): return cand return None def run_prusaslicer_benchmark( stl_path: str, prusa_bin: str, layer_height: float = 0.20, perimeters: int = 2, layer_width: float = 0.45, runs: int = 3, expected_layers: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """Execute PrusaSlicer CLI benchmark and measure execution time.""" tmp_gcode = REPO_ROOT / "tests_and_tools" / "gcode_outputs" / "temp_bench_prusa.gcode" tmp_gcode.parent.mkdir(parents=True, exist_ok=True) cmd = [ prusa_bin, "--export-gcode", f"--layer-height={layer_height}", f"--perimeters={perimeters}", "--fill-density=0%", f"--extrusion-width={layer_width}", f"--output={tmp_gcode}", stl_path, ] timings = [] for r in range(runs): t0 = time.perf_counter() res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) t1 = time.perf_counter() if res.returncode != 0: print(f"\n[Warning] PrusaSlicer failed with exit code {res.returncode}:") print(res.stderr) return None timings.append((t1 - t0) * 1000.0) # Clean up temp file if tmp_gcode.exists(): tmp_gcode.unlink() return { "name": "PrusaSlicer CLI (Baseline)", "backend": "CPU", "pitch_mm": None, "subpixel_lerp": None, "time_ms": float(np.mean(timings)), "std_ms": float(np.std(timings)), "timings": timings, "layers_count": expected_layers, "total_vertices": None, "total_contours": None, } # ============================================================================== # CUDASlicer Benchmark Runner # ============================================================================== def run_cudaslicer_combination( tris: np.ndarray, backend: str, pitch_mm: float, subpixel_lerp: bool, layer_height: float = 0.20, perimeters_count: int = 2, layer_width: float = 0.45, pad_mm: float = 1.5, runs: int = 3, warmup: int = 1, ) -> Dict[str, Any]: """Benchmark a specific CUDASlicer parameter combination.""" # Warmup for _ in range(warmup): _ = cudaslicer.slice_mesh_and_offset( mesh_or_triangles=tris, layer_height=layer_height, perimeters_count=perimeters_count, layer_width=layer_width, pitch_mm=pitch_mm, pad_mm=pad_mm, backend=backend, subpixel_lerp=subpixel_lerp, ) timings = [] last_layers = None for _ in range(runs): t0 = time.perf_counter() last_layers = cudaslicer.slice_mesh_and_offset( mesh_or_triangles=tris, layer_height=layer_height, perimeters_count=perimeters_count, layer_width=layer_width, pitch_mm=pitch_mm, pad_mm=pad_mm, backend=backend, subpixel_lerp=subpixel_lerp, ) t1 = time.perf_counter() timings.append((t1 - t0) * 1000.0) total_vertices = 0 total_contours = 0 if last_layers: for layer in last_layers: for perim in getattr(layer, "perimeters", []): for cnt in perim.get("contours", []): total_contours += 1 total_vertices += len(cnt) return { "name": f"CUDASlicer [{backend.upper()}] {pitch_mm:.2f}mm {'LERP' if subpixel_lerp else 'No LERP'}", "backend": backend.upper(), "pitch_mm": pitch_mm, "subpixel_lerp": subpixel_lerp, "time_ms": float(np.mean(timings)), "std_ms": float(np.std(timings)), "timings": timings, "layers_count": len(last_layers) if last_layers else 0, "total_vertices": total_vertices, "total_contours": total_contours, } # ============================================================================== # Console Reporting & Formatting # ============================================================================== def print_header(title: str) -> None: width = 98 print("\n" + "=" * width) print(f" {title.center(width - 2)} ") print("=" * width) def print_table( results: List[Dict[str, Any]], baseline_time_ms: Optional[float] = None, cpu_ref_map: Optional[Dict[Tuple[float, bool], float]] = None, ) -> None: """Print a clean formatted benchmark summary table to console.""" # Header format hdr = f"{'Configuration':<38} | {'Pitch':<6} | {'LERP':<7} | {'Time (ms)':<11} | {'ms/Layer':<9} | {'Speedup (Base)':<15} | {'GPU vs CPU':<11}" sep = "-" * len(hdr) print("\n" + sep) print(hdr) print(sep) for r in results: name = r["name"] pitch_str = f"{r['pitch_mm']:.2f} mm" if r["pitch_mm"] is not None else "-" lerp_str = "Yes" if r["subpixel_lerp"] is True else ("No" if r["subpixel_lerp"] is False else "-") t_ms = r["time_ms"] num_layers = r["layers_count"] or 1 ms_per_layer = t_ms / num_layers if num_layers > 0 else 0.0 # Baseline speedup if baseline_time_ms is not None and baseline_time_ms > 0: speedup_base = baseline_time_ms / t_ms speedup_base_str = f"{speedup_base:6.2f}x" else: speedup_base_str = "-" # GPU vs CPU speedup for same pitch & lerp gpu_vs_cpu_str = "-" if r["backend"] == "CUDA" and cpu_ref_map: key = (r["pitch_mm"], r["subpixel_lerp"]) if key in cpu_ref_map and cpu_ref_map[key] > 0: gpu_speedup = cpu_ref_map[key] / t_ms gpu_vs_cpu_str = f"{gpu_speedup:6.2f}x" print( f"{name:<38} | {pitch_str:<6} | {lerp_str:<7} | {t_ms:9.2f} ± {r['std_ms']:3.1f} | {ms_per_layer:8.2f} | {speedup_base_str:^15} | {gpu_vs_cpu_str:^11}" ) print(sep + "\n") # ============================================================================== # Main Benchmark CLI # ============================================================================== def main() -> None: parser = argparse.ArgumentParser( description="CUDASlicer Performance Benchmark Suite (PrusaSlicer Baseline vs CPU/GPU, LERP/No LERP, 0.05mm/0.10mm pitch)" ) default_stl = REPO_ROOT / "benchy.stl" parser.add_argument("--stl", type=str, default=str(default_stl), help="Path to STL mesh model (default: benchy.stl)") parser.add_argument("--layer-height", type=float, default=0.20, help="Slicing layer height in mm (default: 0.20)") parser.add_argument("--perimeters", type=int, default=2, help="Number of perimeter passes (default: 2)") parser.add_argument("--layer-width", type=float, default=0.45, help="Extrusion line width in mm (default: 0.45)") parser.add_argument("--pitches", nargs="+", type=float, default=[0.10, 0.05], help="Grid pixel pitches in mm (default: 0.10 0.05)") parser.add_argument("--runs", type=int, default=3, help="Number of benchmark iterations to average (default: 3)") parser.add_argument("--warmup", type=int, default=1, help="Number of warmup runs (default: 1)") parser.add_argument("--no-prusa", action="store_true", help="Skip PrusaSlicer baseline benchmark") parser.add_argument("--prusa-bin", type=str, default=None, help="Path to PrusaSlicer executable") parser.add_argument("--gpu-only", action="store_true", help="Run only GPU (CUDA) benchmarks") parser.add_argument("--cpu-only", action="store_true", help="Run only CPU benchmarks") parser.add_argument("--lerp-only", action="store_true", help="Run only Sub-pixel LERP passes") parser.add_argument("--no-lerp-only", action="store_true", help="Run only No-LERP (discrete) passes") args = parser.parse_args() print_header("CUDASLICER PERFORMANCE BENCHMARK SUITE") # 1. System & Hardware Details stl_path = Path(args.stl).resolve() if not stl_path.exists(): print(f"[Error] Specified STL file does not exist: {stl_path}") sys.exit(1) print(f"Loading Model: {stl_path.name}") tris = load_stl(stl_path) num_tris = len(tris) bounds_min = tris.min(axis=(0, 1)) bounds_max = tris.max(axis=(0, 1)) dimensions = bounds_max - bounds_min print(f" • Triangles: {num_tris:,}") print(f" • Dimensions: {dimensions[0]:.2f} x {dimensions[1]:.2f} x {dimensions[2]:.2f} mm") print(f" • Layer Height: {args.layer_height:.2f} mm") print(f" • Perimeters: {args.perimeters} passes @ {args.layer_width:.2f} mm line width") print(f" • Iterations: {args.runs} runs (warmup: {args.warmup})") # Check cudaslicer availability if not cudaslicer or not cudaslicer.is_available(): print("[Error] cudaslicer native backend is not loaded or available.") sys.exit(1) has_gpu = cudaslicer.has_cuda() print(f" • CUDA GPU: {'Available' if has_gpu else 'Not Available'}") if not has_gpu and not args.cpu_only: print(" (Notice: No CUDA GPU detected, forcing CPU execution)") results: List[Dict[str, Any]] = [] expected_layers = int(np.ceil(dimensions[2] / args.layer_height)) # 2. Benchmark Baseline: PrusaSlicer prusa_res = None if not args.no_prusa: prusa_bin = find_prusaslicer_bin(args.prusa_bin) if prusa_bin: print(f"\n[1/3] Benchmarking Baseline: PrusaSlicer CLI ({Path(prusa_bin).name})...") prusa_res = run_prusaslicer_benchmark( stl_path=str(stl_path), prusa_bin=prusa_bin, layer_height=args.layer_height, perimeters=args.perimeters, layer_width=args.layer_width, runs=args.runs, expected_layers=expected_layers, ) if prusa_res: print(f" -> PrusaSlicer Baseline: {prusa_res['time_ms']:.2f} ± {prusa_res['std_ms']:.2f} ms") results.append(prusa_res) else: print("\n[Warning] PrusaSlicer binary not found. Skipping baseline comparison.") # 3. Build combination matrix backends: list[str] = [] if args.cpu_only: backends = ["cpu"] elif args.gpu_only: backends = ["cuda"] if has_gpu else [] else: backends = (["cuda"] if has_gpu else []) + ["cpu"] lerp_modes: list[bool] = [] if args.lerp_only: lerp_modes = [True] elif args.no_lerp_only: lerp_modes = [False] else: lerp_modes = [True, False] pitches = args.pitches combo_idx = 1 cpu_ref_map: Dict[Tuple[float, bool], float] = {} for backend in backends: for pitch in pitches: if backend.lower() == "cpu" and pitch < 0.09 and not args.cpu_only: # Skip 0.05mm on CPU by default as it operates on 200M+ dense grid on CPU continue for lerp in lerp_modes: lerp_tag = "LERP" if lerp else "No LERP" print( f" [{combo_idx}] Running {backend.upper()} | Pitch: {pitch:.2f}mm | {lerp_tag} ...", end="", flush=True, ) res = run_cudaslicer_combination( tris=tris, backend=backend, pitch_mm=pitch, subpixel_lerp=lerp, layer_height=args.layer_height, perimeters_count=args.perimeters, layer_width=args.layer_width, runs=args.runs, warmup=args.warmup, ) print(f" {res['time_ms']:.2f} ms ({res['time_ms']/res['layers_count']:.2f} ms/layer)") results.append(res) if backend.lower() == "cpu": cpu_ref_map[(pitch, lerp)] = res["time_ms"] combo_idx += 1 # 4. Print Summary Report print_header("BENCHMARK RESULTS & SPEEDUP COMPARISON") baseline_time = prusa_res["time_ms"] if prusa_res else None print_table(results, baseline_time_ms=baseline_time, cpu_ref_map=cpu_ref_map) # Key Highlights gpu_results = [r for r in results if r["backend"] == "CUDA"] if gpu_results and baseline_time: fastest_gpu = min(gpu_results, key=lambda x: x["time_ms"]) speedup = baseline_time / fastest_gpu["time_ms"] print("Performance Highlights:") print(f" ★ Fastest GPU Configuration: {fastest_gpu['name']} in {fastest_gpu['time_ms']:.2f} ms") print(f" ★ GPU Speedup vs PrusaSlicer Baseline: {speedup:.2f}x faster") if cpu_ref_map: key = (fastest_gpu["pitch_mm"], fastest_gpu["subpixel_lerp"]) if key in cpu_ref_map: gpu_vs_cpu = cpu_ref_map[key] / fastest_gpu["time_ms"] print(f" ★ GPU Acceleration vs Multi-threaded CPU: {gpu_vs_cpu:.2f}x faster") if __name__ == "__main__": main()