Keeps the numbers next to the code rather than in a commit message, since the adoption decisions turn on them. Two results are not what the plan assumed: - face_surface_props gains *nothing* from leaving Python — the serial C++ loop costs exactly what the Python loop costs. That agrees with the app's own profile, which found SurfaceProperties to be 94 % of face_candidate_anchors: the time is inside the kernel, so the interpreter round trip was never the problem. The whole 10.9x is OSD_Parallel, reachable only because the GIL is released. - BinTools is already fast enough. Dropping the BytesIO detour saves nothing measurable, so the pools' overhead is not in serialisation — worth knowing before rewriting seventeen call sites for it. Mesh extraction is the clear win at 35x, where the Python loop overhead really does dominate; edge polylines 2.6x. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
224 lines
7.2 KiB
Python
224 lines
7.2 KiB
Python
"""What the n3xd_ocp bulk APIs are worth, against the Python loops they replace.
|
|
|
|
Run under the parity venv, which has both this wheel and numpy:
|
|
|
|
cd ../app && .venv-ocp-parity/bin/python ../ocp/tools/bench_ext.py
|
|
|
|
Each pair measures the same work two ways on the same shape, so the ratio is
|
|
the adoption win for a backend call site — not a comparison between wheels.
|
|
|
|
Measured 2026-08-10 on a 66-face plate (16-core dev box):
|
|
|
|
face props (66 faces) 11.9 ms -> 1.1 ms 10.9x
|
|
... serial (no OSD_Parallel) 11.4 ms -> 11.5 ms 1.0x
|
|
BREP write (73 KiB) 1.1 ms -> 1.0 ms 1.0x
|
|
BREP read 1.6 ms -> 1.5 ms 1.0x
|
|
mesh extract (20412 triangles) 17.3 ms -> 0.5 ms 35.0x
|
|
edge polylines (192 edges) 3.8 ms -> 1.5 ms 2.6x
|
|
|
|
Two of those numbers are worth reading carefully.
|
|
|
|
**face_surface_props gains nothing from leaving Python** — the serial C++ loop
|
|
costs exactly what the Python loop costs. That is consistent with the app's own
|
|
profile, which found SurfaceProperties to be 94 % of face_candidate_anchors:
|
|
the time is genuinely inside the kernel, so the interpreter round trip was never
|
|
the problem. The whole 10.9x is OSD_Parallel, which is only reachable at all
|
|
because the GIL is released.
|
|
|
|
**BinTools is already fast enough.** Dropping the BytesIO detour saves nothing
|
|
measurable at this payload size, so the pools' serialisation is not where their
|
|
overhead lives. Worth knowing before rewriting seventeen call sites for it.
|
|
"""
|
|
|
|
import statistics
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
import n3xd_ocp
|
|
from OCP.BinTools import BinTools
|
|
from OCP.BRep import BRep_Tool
|
|
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
|
from OCP.BRepGProp import BRepGProp
|
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
|
|
from OCP.GCPnts import GCPnts_TangentialDeflection
|
|
from OCP.GProp import GProp_GProps
|
|
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
|
|
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_REVERSED
|
|
from OCP.TopExp import TopExp
|
|
from OCP.TopLoc import TopLoc_Location
|
|
from OCP.TopoDS import TopoDS, TopoDS_Shape
|
|
from OCP.TopTools import TopTools_IndexedMapOfShape, TopTools_ListOfShape
|
|
from io import BytesIO
|
|
|
|
LINEAR, ANGULAR = 0.05, 0.15
|
|
|
|
|
|
def subs(shape, kind):
|
|
smap = TopTools_IndexedMapOfShape()
|
|
TopExp.MapShapes_s(shape, kind, smap)
|
|
return [smap.FindKey(i) for i in range(1, smap.Extent() + 1)]
|
|
|
|
|
|
def build_workpiece(n_holes=60):
|
|
"""A plate with many bores: lots of faces, which is the regime that hurts."""
|
|
body = BRepPrimAPI_MakeBox(200.0, 200.0, 20.0).Shape()
|
|
tools = TopTools_ListOfShape()
|
|
for i in range(n_holes):
|
|
x = 10.0 + (i % 10) * 20.0
|
|
y = 10.0 + (i // 10) * 20.0
|
|
tools.Append(
|
|
BRepPrimAPI_MakeCylinder(
|
|
gp_Ax2(gp_Pnt(x, y, -1.0), gp_Dir(0, 0, 1)), 4.0, 30.0
|
|
).Shape()
|
|
)
|
|
args = TopTools_ListOfShape()
|
|
args.Append(body)
|
|
cut = BRepAlgoAPI_Cut()
|
|
cut.SetArguments(args)
|
|
cut.SetTools(tools)
|
|
cut.SetRunParallel(True)
|
|
cut.Build()
|
|
return cut.Shape()
|
|
|
|
|
|
def timed(fn, repeats=5):
|
|
samples = []
|
|
for _ in range(repeats):
|
|
start = time.perf_counter()
|
|
fn()
|
|
samples.append(time.perf_counter() - start)
|
|
return statistics.median(samples)
|
|
|
|
|
|
def report(label, slow, fast, extra=""):
|
|
print(f"{label:<34} {slow * 1e3:9.1f} ms -> {fast * 1e3:8.1f} ms "
|
|
f"{slow / fast:5.1f}x {extra}")
|
|
|
|
|
|
# --- face measurement ------------------------------------------------------
|
|
|
|
def bench_measure(shape):
|
|
faces = subs(shape, TopAbs_FACE)
|
|
|
|
def python_loop():
|
|
for face in faces:
|
|
props = GProp_GProps()
|
|
BRepGProp.SurfaceProperties_s(face, props)
|
|
centre = props.CentreOfMass()
|
|
(props.Mass(), centre.X(), centre.Y(), centre.Z())
|
|
|
|
report(
|
|
f"face props ({len(faces)} faces)",
|
|
timed(python_loop),
|
|
timed(lambda: n3xd_ocp.measure.face_surface_props(shape)),
|
|
)
|
|
report(
|
|
" ... serial (no OSD_Parallel)",
|
|
timed(python_loop),
|
|
timed(lambda: n3xd_ocp.measure.face_surface_props(shape, parallel=False)),
|
|
)
|
|
|
|
|
|
# --- tessellation ----------------------------------------------------------
|
|
|
|
def bench_tess(shape):
|
|
def python_loop():
|
|
out = []
|
|
for i, raw in enumerate(subs(shape, TopAbs_FACE)):
|
|
face = TopoDS.Face_s(raw)
|
|
loc = TopLoc_Location()
|
|
tri = BRep_Tool.Triangulation_s(face, loc)
|
|
if tri is None:
|
|
continue
|
|
trsf = loc.Transformation()
|
|
identity = loc.IsIdentity()
|
|
rev = face.Orientation() == TopAbs_REVERSED
|
|
verts, idx = [], []
|
|
get_node, get_tri = tri.Node, tri.Triangle
|
|
for n in range(1, tri.NbNodes() + 1):
|
|
node = get_node(n)
|
|
if not identity:
|
|
node.Transform(trsf)
|
|
verts.extend((node.X(), node.Y(), node.Z()))
|
|
for t in range(1, tri.NbTriangles() + 1):
|
|
a, b, c = get_tri(t).Get()
|
|
idx.extend((a - 1, c - 1, b - 1) if rev else (a - 1, b - 1, c - 1))
|
|
out.append({"id": i, "vertices": verts, "indices": idx})
|
|
return out
|
|
|
|
faces = python_loop()
|
|
tris = sum(len(f["indices"]) // 3 for f in faces)
|
|
report(
|
|
f"mesh extract ({tris} triangles)",
|
|
timed(python_loop),
|
|
timed(lambda: n3xd_ocp.tess.extract_meshes(shape)),
|
|
)
|
|
|
|
|
|
def bench_edges(shape):
|
|
edges = subs(shape, TopAbs_EDGE)
|
|
|
|
def python_loop():
|
|
out = []
|
|
for raw in edges:
|
|
curve = BRepAdaptor_Curve(TopoDS.Edge_s(raw))
|
|
points = GCPnts_TangentialDeflection(curve, ANGULAR, LINEAR)
|
|
flat = []
|
|
for p in range(1, points.NbPoints() + 1):
|
|
pt = points.Value(p)
|
|
flat.extend((pt.X(), pt.Y(), pt.Z()))
|
|
out.append(flat)
|
|
return out
|
|
|
|
report(
|
|
f"edge polylines ({len(edges)} edges)",
|
|
timed(python_loop),
|
|
timed(lambda: n3xd_ocp.tess.extract_edge_polylines(shape, ANGULAR, LINEAR)),
|
|
)
|
|
|
|
|
|
# --- serialisation ---------------------------------------------------------
|
|
|
|
def bench_bintools(shape):
|
|
def write_stock():
|
|
buf = BytesIO()
|
|
BinTools.Write_s(shape, buf)
|
|
return buf.getvalue()
|
|
|
|
payload = write_stock()
|
|
|
|
def read_stock():
|
|
out = TopoDS_Shape()
|
|
BinTools.Read_s(out, BytesIO(payload))
|
|
return out
|
|
|
|
report(
|
|
f"BREP write ({len(payload) // 1024} KiB)",
|
|
timed(write_stock),
|
|
timed(lambda: n3xd_ocp.bintools.write_bytes(shape)),
|
|
)
|
|
report(
|
|
"BREP read",
|
|
timed(read_stock),
|
|
timed(lambda: n3xd_ocp.bintools.read_bytes(payload)),
|
|
)
|
|
|
|
|
|
def main():
|
|
print(f"OCP {__import__('OCP').__version__}\n")
|
|
shape = build_workpiece()
|
|
print(f"workpiece: {len(subs(shape, TopAbs_FACE))} faces, "
|
|
f"{len(subs(shape, TopAbs_EDGE))} edges\n")
|
|
print(f"{'':<34} {'python':>12} {'n3xd_ocp':>11} speedup")
|
|
bench_measure(shape)
|
|
bench_bintools(shape)
|
|
BRepMesh_IncrementalMesh(shape, LINEAR, False, ANGULAR, True)
|
|
bench_tess(shape)
|
|
bench_edges(shape)
|
|
|
|
|
|
main()
|