This commit is contained in:
Nicolas Predella 2026-09-26 17:10:47 +03:00
parent 723e763a99
commit 28a4618b40
3 changed files with 288 additions and 1 deletions

View file

@ -1,7 +1,8 @@
import time
from typing import Any, cast, Literal from typing import Any, cast, Literal
import numpy as np 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 ....registry import register_class
from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SlicingGroup from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SlicingGroup
from ...slice import align_path_seam_sharpest from ...slice import align_path_seam_sharpest
@ -185,6 +186,25 @@ class SLICENODE_CudaSliceMesh(SliceNode):
update=notify_tree_changed 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): def init(self, context):
self.add_input("SLICESOCK_Mesh", "Mesh", description="3D mesh geometry to slice") 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 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: if cache is not None and cache_key in cache:
perim_sp, infill_sp = cache[cache_key] perim_sp, infill_sp = cache[cache_key]
else: else:
t_start = time.perf_counter()
coll: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") coll: SlicingCollection | SlicingGroup | None = self.resolve("Mesh")
if not coll: if not coll:
self.execution_time_ms = 0.0
return SlicePaths() return SlicePaths()
layer_height: float = float(self.resolve("Layer Height") or 0.2) layer_height: float = float(self.resolve("Layer Height") or 0.2)
@ -257,6 +279,7 @@ class SLICENODE_CudaSliceMesh(SliceNode):
merged_object = SlicingObject.merge(objects) merged_object = SlicingObject.merge(objects)
if merged_object.mesh is None or merged_object.mesh.size == 0: if merged_object.mesh is None or merged_object.mesh.size == 0:
self.execution_time_ms = 0.0
return SlicePaths() return SlicePaths()
# Copy mesh for rotation # Copy mesh for rotation
@ -374,6 +397,9 @@ class SLICENODE_CudaSliceMesh(SliceNode):
if cache is not None: if cache is not None:
cache[cache_key] = (perim_sp, infill_sp) 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"): if socket_name in ("Perimeters", "Paths"):
return perim_sp return perim_sp
elif socket_name in ("Infill Contours", "Infill"): elif socket_name in ("Infill Contours", "Infill"):

197
tests/profile_node_eval.py Normal file
View file

@ -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()

View file

@ -283,6 +283,70 @@ class TestCudaSlicer(unittest.TestCase):
self.assertGreater(len(perim_paths), 0) self.assertGreater(len(perim_paths), 0)
self.assertGreater(len(infill_contours), 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__": if __name__ == "__main__":
unittest.main() unittest.main()