Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions bindings/module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,34 @@ nb::object profileResultToPy(const std::optional<oscadeval::ProfileResult>& pr)
if (!pr) return nb::none();
nb::list sites;
for (const oscadeval::CallSiteProfile& s : pr->callSites) {
sites.append(facadeAttr("CallSiteProfile")(s.kind, s.name, s.callerName, s.callOrigin, s.callLine, s.declOrigin,
s.declLine, s.callCount, s.selfTime, s.cumulativeTime));
sites.append(facadeAttr("CallSiteProfile")(s.kind, s.name, s.callerName, s.callOrigin, s.callLine, s.callColumn,
s.declOrigin, s.declLine, s.callCount, s.selfTime, s.cumulativeTime));
}
return facadeAttr("ProfileResult")(sites, pr->resolveTime, pr->generateTime, pr->totalTime, pr->unattributedTime);
// The calling-context tree, as plain dicts. Parent/child INDICES, not
// nested objects: the C++ side is already a flat vector keyed that way,
// and a self-referential nesting would have to be rebuilt here for no
// gain -- a consumer walks it by index just as C++ does.
nb::list paths;
for (const oscadeval::ProfilePathNode& n : pr->paths) {
nb::dict d;
d["parent"] = n.parent;
nb::list kids;
for (int c : n.children) kids.append(c);
d["children"] = kids;
d["kind"] = n.kind;
d["name"] = n.name;
d["call_origin"] = n.callOrigin;
d["call_line"] = n.callLine;
d["call_column"] = n.callColumn;
d["decl_origin"] = n.declOrigin;
d["decl_line"] = n.declLine;
d["call_count"] = n.callCount;
d["self_time"] = n.selfTime;
d["cumulative_time"] = n.cumulativeTime;
paths.append(d);
}
return facadeAttr("ProfileResult")(sites, pr->resolveTime, pr->generateTime, pr->totalTime, pr->unattributedTime,
paths);
}

// ctx.dyn / ctx.dynExplicit -> (dict[str, Any], set[str]) -- every currently-
Expand Down
15 changes: 15 additions & 0 deletions include/openscad_cpp_evaluator/eval_context.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ struct EvalContext {
std::shared_ptr<const ChildrenNodeList> childrenNodes;
const EvalContext* childrenCallerCtx = nullptr;

// True while evaluating statements that a children() forwarded, so a
// module call reached this way profiles as kind "child" rather than
// "module". `foo() foo();` and `module foo() { foo(); }` both produce a
// foo->foo edge, but only the second is recursion, and a profile that
// cannot tell them apart is misleading.
//
// Propagates through withScope/childCtx (a forwarded `translate()
// foo()` is still child-passing) but NOT through callCtx, which resets
// it by omission -- entering a body ends the forwarding. The one
// deliberate exception is buildModuleChildCtx, which copies the
// caller's flag onto the body ctx so enterUserCall can read it there;
// enterUserCall then clears it, so the call it describes is the only
// one that sees it.
bool viaChildren = false;

// The one genuinely fresh construction: seeds `dyn` with OpenSCAD's
// built-in $-variable defaults ($fn=0, $fa=12, $fs=2, $t=0,
// $parent_modules=0). Every other EvalContext in a run is derived
Expand Down
43 changes: 41 additions & 2 deletions include/openscad_cpp_evaluator/evaluator.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1234,12 +1234,16 @@ class Evaluator {

// -- Profiling (Phase 9) --------------------------------------------

// (kind, name, callOrigin, callLine) -- the exact call-site identity a
// (kind, name, callOrigin, callLine, callColumn) -- the exact call-site
// identity a
// CallSiteProfile aggregates by. std::map (not unordered_map): a plain
// tuple of comparable fields already gets a free operator< from the
// standard library, so this needs no custom hash function for a table
// that's never more than a few thousand entries even on a large script.
using ProfileSiteKey = std::tuple<std::string, std::string, std::string, int>;
// Column is part of the key, not decoration: `foo() foo();` and
// `f(g(x))` put two distinct call sites on one line, and without it
// they aggregate into one entry whose times belong to neither.
using ProfileSiteKey = std::tuple<std::string, std::string, std::string, int, int>;

// Pushes profiling state for a user module/function call about to
// start -- shared by evalUserModule/evalUserFunction/
Expand All @@ -1252,6 +1256,13 @@ class Evaluator {
ProfileSiteKey key;
bool recursiveReentry;
std::chrono::steady_clock::time_point start;
// Node this call occupies in the calling-context tree, and the one
// to restore on exit. `pathRecursive` is the per-PATH equivalent of
// recursiveReentry: true when this entry folded back onto a node
// already on the path, so only the outermost entry adds cumulative
// time -- the same rule callSites uses, applied per node.
int pathNode = -1;
int pathPrev = -1;
};
std::optional<ProfileHandle> profileEnter(const std::string& kind, const std::string& name,
const oscad::Position* callPos, const oscad::Position* declPos);
Expand Down Expand Up @@ -1712,6 +1723,34 @@ class Evaluator {
std::map<ProfileSiteKey, CallSiteProfile> profileSites_;
std::set<ProfileSiteKey> profileActive_; // site keys with a call currently on callStack_
std::vector<double> profileChildTime_; // parallel aux stack to callStack_

// Calling-context tree (see ProfilePathNode). profilePaths_[0] is the
// <toplevel> root, created lazily on the first profiled call;
// profileCurrentPath_ is the node the running call belongs to, so a
// profileEnter knows which parent to hang its node off.
std::vector<ProfilePathNode> profilePaths_;
int profileCurrentPath_ = -1;

// Safety valve. The tree is bounded by distinct ACYCLIC paths (see
// ProfilePathNode on recursion folding), which is finite but can still
// be large for a deeply-layered library. Past this, no new nodes are
// created and further calls fold onto their parent -- the report stays
// truthful about totals and simply stops subdividing, rather than the
// profiler becoming the thing that runs out of memory.
static constexpr size_t kMaxProfilePathNodes = 200000;

// Finds-or-creates the child of profileCurrentPath_ for this call site
// and returns its index. Sets `folded` when the returned node was
// already on the current path (recursion, or the node cap) rather than
// a fresh or sibling child -- the caller must then skip cumulative
// time for this entry, exactly as recursiveReentry does for callSites.
int profilePathEnter(const std::string& kind, const std::string& name, const std::string& callOrigin,
int callLine, int callColumn, const oscad::Position* declPos, bool& folded);

// Fills in every node's cumulativeTime as selfTime + the cumulative of
// its children, bottom-up. See profileExit for why this is derived
// rather than measured.
void finalizeProfilePaths();
};

} // namespace oscadeval
40 changes: 39 additions & 1 deletion include/openscad_cpp_evaluator/profile.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,51 @@ namespace oscadeval {
// node (and thus its position) is identical across those invocations.
// Mirrors the reference's CallSiteProfile.
struct CallSiteProfile {
std::string kind; // "module" | "function"
std::string kind; // "module" | "child" (forwarded via children()) | "function"
std::string name;
std::string callerName; // enclosing module/function's own name, or "<toplevel>"
std::string callOrigin;
int callLine = 0;
int callColumn = 0; // distinguishes two calls sharing one line
std::string declOrigin;
int declLine = 0;
int callCount = 0;
double selfTime = 0.0; // seconds, own code only, never double-counted
double cumulativeTime = 0.0; // seconds, includes children; recursion-guarded
};

// One node of the calling-context tree: a call site reached by ONE
// specific path from <toplevel>. Where CallSiteProfile aggregates a site
// over every path that reached it, this keeps them separate -- so
// `cuboid` called from `bracket` and `cuboid` called from `rail` are two
// nodes with their own times, and a consumer can show what a particular
// path actually cost rather than a total across all callers.
//
// Flat vector with parent/child indices rather than pointers: it survives
// the vector reallocating as nodes are appended, and crosses the Python
// binding as plain data with no ownership question.
//
// Recursion is folded rather than unrolled. Re-entering a call site
// already on the current path reuses that node instead of appending a new
// child, so `fib` calling itself 400 deep is one node with callCount 400,
// not a 400-node chain. That bounds the tree by distinct ACYCLIC paths and
// keeps the recursion-guarded cumulative-time rule (only the outermost
// entry contributes) meaningful per node.
struct ProfilePathNode {
int parent = -1; // index into ProfileResult::paths; -1 for the root
std::vector<int> children; // indices, in first-call order
std::string kind; // "module" | "child" | "function"; empty for the root
std::string name; // callee; "<toplevel>" for the root
std::string callOrigin;
int callLine = 0;
int callColumn = 0; // distinguishes two calls sharing one line
std::string declOrigin;
int declLine = 0;
int callCount = 0;
double selfTime = 0.0; // seconds, this node's own code on this path
double cumulativeTime = 0.0; // seconds, this node and everything under it
};

// Whole-evaluate() profiling summary, built when Evaluator is constructed
// with profiling=true. unattributedTime covers top-level script code and
// anything else not inside a user module/function call (native builtins'
Expand All @@ -34,6 +67,11 @@ struct CallSiteProfile {
// ProfileResult.
struct ProfileResult {
std::vector<CallSiteProfile> callSites;
// The calling-context tree; paths[0] is always the <toplevel> root.
// Empty only if profiling was off. callSites stays exactly as it was --
// the flat per-site view is still the right answer to "what is
// expensive overall", and is cheaper to scan than walking this.
std::vector<ProfilePathNode> paths;
double resolveTime = 0.0;
double generateTime = 0.0;
double totalTime = 0.0;
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"

[project]
name = "openscad_cpp_evaluator"
version = "0.18.0"
version = "0.21.0"
description = "C++ OpenSCAD evaluator with Python bindings"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
22 changes: 20 additions & 2 deletions python/openscad_cpp_evaluator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
`cb.body.to_mesh().vert_properties/tri_verts/run_original_id/run_index`
exactly as before -- no cross-module Manifold objects are ever exchanged.
"""
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Optional

from . import _openscad_cpp_evaluator as _ext
Expand Down Expand Up @@ -142,11 +142,12 @@ def __init__(self, kind, params, bodies, is_builtin, children):
class CallSiteProfile:
"""Aggregated profiling data for one call site. Mirrors the reference's
CallSiteProfile -- see ProfileResult's own docstring."""
kind: str # "module" | "function"
kind: str # "module" | "child" | "function"
name: str
caller_name: str
call_origin: str
call_line: int
call_column: int
decl_origin: str
decl_line: int
call_count: int = 0
Expand All @@ -165,6 +166,23 @@ class ProfileResult:
generate_time: float
total_time: float
unattributed_time: float
# Calling-context tree: a flat list of dicts, paths[0] the <toplevel>
# root, linked by `parent`/`children` INDICES into this same list.
#
# Where call_sites aggregates a site over every path that reached it,
# each node here is one call site on ONE path -- so `cuboid` called
# from `bracket` and from `rail` are separate nodes with their own
# times, and a report can say what a particular path cost rather than
# only what a name cost in total.
#
# Keys: parent, children, kind, name, call_origin, call_line,
# decl_origin, decl_line, call_count, self_time, cumulative_time.
#
# Recursion is folded: re-entering a site already on the path reuses
# that node (call_count rises) instead of unrolling a node per level.
# Defaults to empty so a ProfileResult built by older code still
# constructs.
paths: list = field(default_factory=list)


def _summarize_param(value, max_items: int = 6, max_len: int = 40) -> str:
Expand Down
4 changes: 3 additions & 1 deletion src/csg_resolve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,10 @@ std::vector<ColoredBody> Evaluator::evaluateImpl(const NodeList& nodes, EvalCont
selfSum += site.selfTime;
sites.push_back(site);
}
finalizeProfilePaths();
profileResult = ProfileResult{
std::move(sites), resolveTime, generateTime, resolveTime + generateTime, std::max(0.0, resolveTime - selfSum),
std::move(sites), profilePaths_, resolveTime, generateTime, resolveTime + generateTime,
std::max(0.0, resolveTime - selfSum),
};
}

Expand Down
Loading