Performance improvements, better readme and complete python bindings
🧪 Test / test (push) Failing after 28m30s

This commit is contained in:
2026-05-12 21:23:33 +02:00
parent c39db82763
commit f6bcaa15b0
38 changed files with 4679 additions and 846 deletions
+98 -44
View File
@@ -26,6 +26,9 @@ namespace nb = nanobind;
// them via IVariantChannel adapters. The variant only lives at the boundary;
// each node's internal Channel<T> stores raw T values.
template<typename Variant>
class PyNode; // forward declaration
template<typename Variant>
class PyNetwork {
public:
@@ -43,8 +46,6 @@ public:
}
// connect(src_name, out_idx, dst_name, in_idx)
// Wires src's output port out_idx to dst's input port in_idx.
// Type check: both sides must carry the same T.
void connect(const std::string& src_name, std::size_t out_idx,
const std::string& dst_name, std::size_t in_idx)
{
@@ -65,7 +66,6 @@ public:
dst_name + ".input[" + std::to_string(in_idx) +
"] (" + dst.input_type(in_idx).name() + ")");
// The destination node owns the input channel — get it, then tell src to use it.
auto ch = dst.input_channel(in_idx);
src.set_output_channel(out_idx, std::move(ch));
adj_[src_name].push_back(dst_name);
@@ -92,16 +92,12 @@ public:
// ── Python tap/inject ─────────────────────────────────────────────────────
// Read one value from node's output port. Releases GIL while blocking.
nb::object read(const std::string& node_name, std::size_t out_idx) {
// We need a channel that sits on the output of this node.
// read() installs a tap channel if not already present.
auto key = tap_key(node_name, out_idx);
if (!taps_.count(key)) {
auto& src = node_at(node_name);
if (out_idx >= src.output_count())
throw std::out_of_range(node_name + ": output index out of range");
// Create a tap channel matching the output type and wire it
auto tap = make_tap_channel(src.output_type(out_idx));
src.set_output_channel(out_idx, tap);
taps_[key] = std::move(tap);
@@ -114,7 +110,6 @@ public:
return variant_to_python(std::move(v));
}
// Write a Python value into node's input port. Releases GIL while blocking.
void write(const std::string& node_name, std::size_t in_idx, nb::object value) {
auto& dst = node_at(node_name);
if (in_idx >= dst.input_count())
@@ -128,8 +123,31 @@ public:
}
}
// ── Converter registration ────────────────────────────────────────────────
// Called once per type at module init time to register to/from Python converters.
// ── Python-callable node creation ─────────────────────────────────────────
// Creates a PyNode wrapping a Python callable and adds it to the graph.
// Type names must have been registered via register_full_type<T>().
void add_node_python(std::string name, nb::object callable,
std::vector<std::string> in_names,
std::vector<std::string> out_names,
std::size_t capacity = 5)
{
std::vector<std::type_index> in_types, out_types;
for (auto& s : in_names) in_types.push_back(resolve_type_name(s));
for (auto& s : out_names) out_types.push_back(resolve_type_name(s));
add(std::move(name),
std::make_shared<PyNode<Variant>>(
std::move(callable),
std::move(in_types),
std::move(out_types),
to_python_,
from_python_,
ch_factories_,
capacity));
}
// ── Type converter registration ───────────────────────────────────────────
template<typename T>
void register_type(
@@ -143,6 +161,52 @@ public:
};
}
// register_channel_factory<T>: registers factory for creating input channels.
template<typename T>
void register_channel_factory() {
ch_factories_[std::type_index(typeid(T))] =
[](std::size_t cap) -> std::shared_ptr<VChannel> {
return std::make_shared<VariantChannel<T, Variant>>(
std::make_shared<Channel<T>>(cap));
};
}
// Backward-compatible alias.
template<typename T>
void register_tap_factory(std::size_t = 5) {
register_channel_factory<T>();
}
// register_full_type<T>: registers converters + channel factory + type name.
// This is what auto_bind.hpp calls; manual bindings can call register_type +
// register_tap_factory separately for backward compatibility.
template<typename T>
void register_full_type(
std::function<nb::object(const T&)> to_py,
std::function<T(nb::object)> from_py,
const char* friendly_name = nullptr)
{
register_type<T>(std::move(to_py), std::move(from_py));
register_channel_factory<T>();
auto idx = std::type_index(typeid(T));
type_names_.insert_or_assign(typeid(T).name(), idx);
if (friendly_name) type_names_.insert_or_assign(friendly_name, idx);
}
// ── Type name lookup ──────────────────────────────────────────────────────
void register_type_name(const std::string& name, std::type_index idx) {
type_names_.insert_or_assign(name, idx);
}
std::type_index resolve_type_name(const std::string& name) const {
auto it = type_names_.find(name);
if (it == type_names_.end())
throw std::runtime_error(
"type '" + name + "' not registered — call register_full_type<T>() first");
return it->second;
}
private:
VNode& node_at(const std::string& name) {
auto it = nodes_.find(name);
@@ -166,15 +230,14 @@ private:
return node + ":" + std::to_string(idx);
}
std::shared_ptr<VChannel> make_tap_channel(std::type_index type) {
// Create the right VariantChannel<T> based on the registered type index.
// We need a factory registered per type — stored in tap_factories_.
auto it = tap_factories_.find(type);
if (it == tap_factories_.end())
std::shared_ptr<VChannel> make_tap_channel(std::type_index type,
std::size_t cap = 5) {
auto it = ch_factories_.find(type);
if (it == ch_factories_.end())
throw std::runtime_error(
"no tap factory for type: " + std::string(type.name()) +
"was register_type() called for this type?");
return it->second();
"no channel factory for type: " + std::string(type.name()) +
"call register_full_type<T>() or register_tap_factory<T>()");
return it->second(cap);
}
nb::object variant_to_python(Variant v) {
@@ -194,18 +257,6 @@ private:
return it->second(std::move(obj));
}
public:
// Called by register_type to also register a tap channel factory.
template<typename T>
void register_tap_factory(std::size_t capacity = 5) {
auto idx = std::type_index(typeid(T));
tap_factories_[idx] = [capacity]() -> std::shared_ptr<VChannel> {
auto ch = std::make_shared<Channel<T>>(capacity);
return std::make_shared<VariantChannel<T, Variant>>(std::move(ch));
};
}
private:
std::map<std::string, std::shared_ptr<VNode>> nodes_;
std::map<std::string, std::vector<std::string>> adj_;
std::vector<std::string> topo_;
@@ -213,19 +264,27 @@ private:
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
std::map<std::type_index, std::function<std::shared_ptr<VChannel>()>> tap_factories_;
// Channel factory: type → function(capacity) → VChannel.
// Used both for tap channels (read()) and PyNode input channel creation.
std::map<std::type_index,
std::function<std::shared_ptr<VChannel>(std::size_t)>> ch_factories_;
// Friendly name → type_index (e.g. "int" → typeid(int)).
std::map<std::string, std::type_index> type_names_;
};
// ── PyNode<Variant> ───────────────────────────────────────────────────────────
// A pure-Python processing node. Holds a nanobind callable.
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs (release GIL).
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs.
template<typename Variant>
class PyNode : public IVariantNode<Variant> {
public:
using VChannel = IVariantChannel<Variant>;
using ChannelFactory = std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
using ChannelFactory =
std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
PyNode(nb::object callable,
std::vector<std::type_index> in_types,
@@ -264,7 +323,6 @@ public:
for (auto& ch : in_channels_) ch->disable();
if (thread_.joinable()) {
thread_.request_stop();
// Release GIL while joining — run_loop may be waiting to acquire it.
nb::gil_scoped_release release;
thread_.join();
}
@@ -311,20 +369,17 @@ public:
private:
void run_loop() {
// This thread does not hold the GIL. It acquires it only for Python calls.
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
// Pop all inputs — no GIL needed, these are pure C++ channel ops
std::vector<Variant> inputs(in_channels_.size());
for (std::size_t i = 0; i < in_channels_.size(); ++i)
inputs[i] = in_channels_[i]->pop();
auto t1 = clock_t::now();
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
// Acquire GIL only for the Python call and type conversion
std::vector<Variant> outputs;
{
nb::gil_scoped_acquire acquire;
@@ -344,10 +399,9 @@ private:
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
// Push outputs — no GIL needed
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
if (out_channels_[i])
out_channels_[i]->push(std::move(outputs[i]));
@@ -385,9 +439,9 @@ private:
NodeStats stats_;
};
// ── register_py_network ───────────────────────────────────────────────────────
// Registers PyNetwork<Variant> and PyNode<Variant> with the given nanobind module.
// Call once per module, passing the Variant type derived from your registered node types.
// ── register_py_network (legacy helper) ───────────────────────────────────────
// Registers PyNetwork<Variant> with the given nanobind module.
// Prefer bind_network<Registry> from auto_bind.hpp for new code.
template<typename Variant>
void register_py_network(nb::module_& m, const char* class_name = "Network") {
@@ -402,7 +456,7 @@ void register_py_network(nb::module_& m, const char* class_name = "Network") {
.def("start", &Net::start)
.def("stop", &Net::stop)
.def("read", &Net::read,
nb::arg("node"), nb::arg("out_idx") = 0)
nb::arg("node"), nb::arg("out_idx") = std::size_t(0))
.def("write", &Net::write,
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
}