From a3e25a0566ceee7b02a2a326b82566119acd2d4f Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Sat, 26 Sep 2026 17:26:43 +0300 Subject: [PATCH] benchmark --- benchmark.py | 439 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100755 benchmark.py diff --git a/benchmark.py b/benchmark.py new file mode 100755 index 0000000..5e9c3be --- /dev/null +++ b/benchmark.py @@ -0,0 +1,439 @@ +#!/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()