cudaslicer/benchmark.py
2026-09-26 17:23:01 +03:00

439 lines
16 KiB
Python
Executable file

#!/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("<I", num_triangles_bytes)[0]
expected_size = 84 + num_triangles * 50
# Check if binary STL matches expected file size
if expected_size == file_size:
f.seek(84)
record_dtype = np.dtype([
("normal", np.float32, 3),
("v0", np.float32, 3),
("v1", np.float32, 3),
("v2", np.float32, 3),
("attr", np.uint16),
])
data = np.fromfile(f, dtype=record_dtype, count=num_triangles)
return np.stack([data["v0"], data["v1"], data["v2"]], axis=1)
# Fallback to ASCII parser
vertices: list[list[float]] = []
with open(p, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
line_str = line.strip()
if line_str.startswith("vertex"):
parts = line_str.split()
if len(parts) >= 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()