From 28a4618b401111829e1d6fbc6563c6226c18c0da Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Sat, 26 Sep 2026 17:10:47 +0300 Subject: [PATCH] up --- core/nodeslicer/nodes/path.py | 28 ++++- tests/profile_node_eval.py | 197 ++++++++++++++++++++++++++++++++++ tests/test_cudaslicer.py | 64 +++++++++++ 3 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 tests/profile_node_eval.py diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 60180a7..6b162c2 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -1,7 +1,8 @@ +import time from typing import Any, cast, Literal import numpy as np -from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, notify_tree_changed +from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed from ....registry import register_class from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SlicingGroup from ...slice import align_path_seam_sharpest @@ -185,6 +186,25 @@ class SLICENODE_CudaSliceMesh(SliceNode): update=notify_tree_changed ) + execution_time_ms: float = NfFloatProperty( + name="Execution Time (ms)", + description="Last execution time in milliseconds", + default=0.0, + update=lambda s, c: None + ) + + def draw_label(self) -> str: + base = getattr(self, "label", None) or self.bl_label + time_ms = getattr(self, "execution_time_ms", 0.0) + if time_ms and time_ms > 0.0: + if time_ms >= 10.0: + return f"{base} ({int(round(time_ms))}ms)" + elif time_ms >= 1.0: + return f"{base} ({time_ms:.1f}ms)" + else: + return f"{base} ({time_ms:.2f}ms)" + return base + def init(self, context): self.add_input("SLICESOCK_Mesh", "Mesh", description="3D mesh geometry to slice") self.add_input("NodeSocketFloat", "Layer Height", description="Layer slicing height in millimeters (Z distance between consecutive layers)").value = 0.2 @@ -215,8 +235,10 @@ class SLICENODE_CudaSliceMesh(SliceNode): if cache is not None and cache_key in cache: perim_sp, infill_sp = cache[cache_key] else: + t_start = time.perf_counter() coll: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") if not coll: + self.execution_time_ms = 0.0 return SlicePaths() layer_height: float = float(self.resolve("Layer Height") or 0.2) @@ -257,6 +279,7 @@ class SLICENODE_CudaSliceMesh(SliceNode): merged_object = SlicingObject.merge(objects) if merged_object.mesh is None or merged_object.mesh.size == 0: + self.execution_time_ms = 0.0 return SlicePaths() # Copy mesh for rotation @@ -374,6 +397,9 @@ class SLICENODE_CudaSliceMesh(SliceNode): if cache is not None: cache[cache_key] = (perim_sp, infill_sp) + t_end = time.perf_counter() + self.execution_time_ms = (t_end - t_start) * 1000.0 + if socket_name in ("Perimeters", "Paths"): return perim_sp elif socket_name in ("Infill Contours", "Infill"): diff --git a/tests/profile_node_eval.py b/tests/profile_node_eval.py new file mode 100644 index 0000000..7fb83e0 --- /dev/null +++ b/tests/profile_node_eval.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +import time +from pathlib import Path +import numpy as np + +import sys +repo_root = Path(__file__).resolve().parent.parent +if str(repo_root.parent) not in sys.path: + sys.path.insert(0, str(repo_root.parent)) +if str(repo_root) not in sys.path: + sys.path.insert(0, str(repo_root)) + +from unittest.mock import MagicMock +class DummyNodeSocket: pass +class DummyNode: pass +class DummyObject: + def __init__(self, name="TestObj"): + self.name = name +class DummyMesh: pass +class DummyNodeSocketShader: pass +class DummyNodeCustomGroup: pass +class DummyNodeTree: pass +class DummyOperator: pass +class DummyMenu: pass +class DummyPanel: pass +class DummyPropertyGroup: pass + +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +mock_types.Object = DummyObject +mock_types.Mesh = DummyMesh +mock_types.NodeSocketShader = DummyNodeSocketShader +mock_types.NodeCustomGroup = DummyNodeCustomGroup +mock_types.NodeTree = DummyNodeTree +mock_types.Operator = DummyOperator +mock_types.Menu = DummyMenu +mock_types.Panel = DummyPanel +mock_types.PropertyGroup = DummyPropertyGroup + +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.context.scene.unit_settings.scale_length = 0.001 +mock_bpy.types = mock_types + +mock_previews = MagicMock() +mock_previews.ImagePreviewCollection = MagicMock + +sys.modules['bpy'] = mock_bpy +sys.modules['bpy.types'] = mock_types +sys.modules['bpy.props'] = MagicMock() +sys.modules['bpy.utils'] = MagicMock() +sys.modules['bpy.utils.previews'] = mock_previews +sys.modules['bpy.app'] = MagicMock() +sys.modules['bpy.app.handlers'] = MagicMock() + +from NodeSlicer.core.cudaslicer_loader import import_cudaslicer +from NodeSlicer.core.slice import align_path_seam_sharpest, SlicePaths +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject + +def load_stl(file_path): + with open(file_path, "rb") as f: + f.seek(80) + num_tris = int(np.frombuffer(f.read(4), dtype=np.uint32)[0]) + 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_tris) + tris = np.stack([data["v0"], data["v1"], data["v2"]], axis=1) # (N, 3, 3) + normals = np.zeros((num_tris, 1, 3), dtype=np.float32) + mesh = np.concatenate([tris, normals], axis=1) # (N, 4, 3) + return mesh + +def profile_node_pipeline(): + stl_path = "/home/nicolas/Workspace/Development/cudaslicer/benchy.stl" + mesh_data = load_stl(stl_path) + so = SlicingObject() + so.mesh = mesh_data + objects = [so] + + cudaslicer = import_cudaslicer() + + layer_height = 0.20 + fl_height = 0.20 + perimeters_count = 2 + layer_width = 0.45 + pitch_mm = 0.10 + pad_mm = 1.5 + backend_choice = "AUTO" + + # Warmup + _ = cudaslicer.slice_mesh_to_sdf(mesh_data[:100, :3, :], layer_height=1.0, pitch_mm=0.10) + + print("==========================================================================") + print(f" PROFILING SLICENODE_CudaSliceMesh.evaluate_output (Benchy: {len(mesh_data):,} tris)") + print("==========================================================================") + + t_total_0 = time.perf_counter() + + # Step 1: Object merge + t0 = time.perf_counter() + merged_object = SlicingObject.merge(objects) + t_merge = (time.perf_counter() - t0) * 1000.0 + + # Step 2: Mesh array copy & float32 cast + t0 = time.perf_counter() + mesh_data_copy = merged_object.mesh.copy() + tris_3d = mesh_data_copy[:, :3, :].astype(np.float32) + t_copy = (time.perf_counter() - t0) * 1000.0 + + # Step 3: cudaslicer.slice_mesh_and_offset + t0 = time.perf_counter() + layers = cudaslicer.slice_mesh_and_offset( + mesh_or_triangles=tris_3d, + layer_height=float(layer_height), + first_layer_height=float(fl_height), + perimeters_count=int(perimeters_count), + layer_width=float(layer_width), + pitch_mm=float(pitch_mm), + pad_mm=float(pad_mm), + return_infill_mask=True, + backend="cpu" if backend_choice == "CPU" else backend_choice.lower(), + ) + t_cudaslicer = (time.perf_counter() - t0) * 1000.0 + + # Step 4: Python conversion loop (unpacking contours, column_stack, vstack, align_path_seam_sharpest) + perim_paths = [] + perim_groups = [] + infill_paths = [] + infill_groups = [] + + t0 = time.perf_counter() + t_seam_align = 0.0 + t_stack = 0.0 + + for l_idx, layer in enumerate(layers): + z = float(layer.z) + for perim in getattr(layer, "perimeters", []): + for cnt in perim.get("contours", []): + if len(cnt) < 2: + continue + t_st0 = time.perf_counter() + cnt_3d = np.column_stack([cnt, np.full(len(cnt), z)]) + if not np.allclose(cnt_3d[0], cnt_3d[-1]): + cnt_3d = np.vstack([cnt_3d, cnt_3d[0]]) + t_stack += (time.perf_counter() - t_st0) * 1000.0 + + t_s0 = time.perf_counter() + cnt_3d = align_path_seam_sharpest(cnt_3d) + t_seam_align += (time.perf_counter() - t_s0) * 1000.0 + + perim_paths.append(cnt_3d) + perim_groups.append(l_idx) + + for cnt in getattr(layer, "infill_contours", []): + if len(cnt) < 2: + continue + t_st0 = time.perf_counter() + cnt_3d = np.column_stack([cnt, np.full(len(cnt), z)]) + if not np.allclose(cnt_3d[0], cnt_3d[-1]): + cnt_3d = np.vstack([cnt_3d, cnt_3d[0]]) + t_stack += (time.perf_counter() - t_st0) * 1000.0 + + t_s0 = time.perf_counter() + cnt_3d = align_path_seam_sharpest(cnt_3d) + t_seam_align += (time.perf_counter() - t_s0) * 1000.0 + + infill_paths.append(cnt_3d) + infill_groups.append(l_idx) + + t_loop_total = (time.perf_counter() - t0) * 1000.0 + + # Step 5: SlicePaths object instantiation + t0 = time.perf_counter() + perim_sp = SlicePaths(perim_paths, groups=perim_groups) + infill_sp = SlicePaths(infill_paths, groups=infill_groups) + t_slicepaths = (time.perf_counter() - t0) * 1000.0 + + t_total = (time.perf_counter() - t_total_0) * 1000.0 + + print(f" 1. SlicingObject.merge: {t_merge:7.2f} ms") + print(f" 2. Mesh copy & astype(float32): {t_copy:7.2f} ms") + print(f" 3. cudaslicer.slice_mesh_and_offset: {t_cudaslicer:7.2f} ms") + print(f" 4. Python Loop Total: {t_loop_total:7.2f} ms") + print(f" • np.column_stack & np.vstack: {t_stack:7.2f} ms") + print(f" • align_path_seam_sharpest (Python): {t_seam_align:7.2f} ms") + print(f" 5. SlicePaths instantiation: {t_slicepaths:7.2f} ms") + print(f"--------------------------------------------------------------------------") + print(f" TOTAL evaluate_output Wall Time: {t_total:7.2f} ms") + print("==========================================================================") + +if __name__ == "__main__": + profile_node_pipeline() diff --git a/tests/test_cudaslicer.py b/tests/test_cudaslicer.py index 11321a5..6963b4c 100644 --- a/tests/test_cudaslicer.py +++ b/tests/test_cudaslicer.py @@ -283,6 +283,70 @@ class TestCudaSlicer(unittest.TestCase): self.assertGreater(len(perim_paths), 0) self.assertGreater(len(infill_contours), 0) + def test_cuda_slice_mesh_timing_in_label(self): + node = SLICENODE_CudaSliceMesh() + node.name = "CUDA_Slice_Mesh" + + # Initially no execution time recorded -> default label + self.assertEqual(node.draw_label(), "CUDA Slice Mesh") + + # Test simulated timing values and label formatting + node.execution_time_ms = 10.2 + self.assertEqual(node.draw_label(), "CUDA Slice Mesh (10ms)") + + node.execution_time_ms = 35.8 + self.assertEqual(node.draw_label(), "CUDA Slice Mesh (36ms)") + + node.execution_time_ms = 4.2 + self.assertEqual(node.draw_label(), "CUDA Slice Mesh (4.2ms)") + + node.execution_time_ms = 0.35 + self.assertEqual(node.draw_label(), "CUDA Slice Mesh (0.35ms)") + + # Custom user label support + node.label = "My Benchy Slicer" + node.execution_time_ms = 12.0 + self.assertEqual(node.draw_label(), "My Benchy Slicer (12ms)") + + # After real evaluation, timing should be automatically measured and formatted + node.label = "" + so = SlicingObject() + so.name = "TestCube" + tris = np.array([ + [[0,0,0], [10,0,0], [10,10,0]], + [[0,0,0], [10,10,0], [0,10,0]], + [[0,0,10], [10,10,10], [10,0,10]], + [[0,0,10], [0,10,10], [10,10,10]], + [[0,0,0], [0,10,0], [0,10,10]], + [[0,0,0], [0,10,10], [0,0,10]], + [[10,0,0], [10,10,10], [10,10,0]], + [[10,0,0], [10,10,0], [10,10,10]], + [[0,0,0], [10,0,10], [10,0,0]], + [[0,0,0], [0,0,10], [10,0,10]], + [[0,10,0], [10,10,0], [10,10,10]], + [[0,10,0], [10,10,10], [0,10,10]], + ], dtype=np.float64) + so.mesh = np.concatenate([tris, np.zeros((12, 1, 3))], axis=1) + + resolve_map = { + "Mesh": so, + "Layer Height": 1.0, + "First Layer Height": 0.5, + "Perimeters Count": 2, + "Layer Width": 0.45, + "Pitch": 0.10, + "Pad": 1.5, + "Rotation": (0.0, 0.0, 0.0), + } + node.resolve = lambda key: resolve_map.get(key) + node.backend = "AUTO" + + node.evaluate_output("Perimeters") + self.assertGreater(node.execution_time_ms, 0.0) + label = node.draw_label() + self.assertTrue(label.startswith("CUDA Slice Mesh (") and label.endswith("ms)"), f"Label was: {label}") + if __name__ == "__main__": unittest.main() +