Performance improvements, better readme and complete python bindings
🧪 Test / test (push) Failing after 28m30s
🧪 Test / test (push) Failing after 28m30s
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
#pragma once
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
#include "traits.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── RouterNode ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reads one item and pushes it to exactly one of N output channels, chosen by
|
||||
// selector(item). If selector returns >= N the item is silently dropped.
|
||||
//
|
||||
// Usage:
|
||||
// auto router = make_router<Image, 3>(
|
||||
// [](const Image& img) -> std::size_t { return img.stream_id % 3; });
|
||||
// net.connect("src", src.output<0>(), "router", router.input<0>())
|
||||
// .connect("router", router.output<0>(), "nodeA", nodeA.input<0>())
|
||||
// .connect("router", router.output<1>(), "nodeB", nodeB.input<0>())
|
||||
// .connect("router", router.output<2>(), "nodeC", nodeC.input<0>());
|
||||
|
||||
template<typename T, std::size_t N, std::size_t Id = 0>
|
||||
class RouterNode : public INode {
|
||||
public:
|
||||
using Selector = std::function<std::size_t(const T&)>;
|
||||
using args_tuple = std::tuple<T>;
|
||||
using return_tuple = repeat_tuple_t<T, N>;
|
||||
using return_raw = return_tuple;
|
||||
|
||||
static constexpr std::size_t input_count = 1;
|
||||
static constexpr std::size_t output_count = N;
|
||||
static constexpr std::size_t unique_tag = Id;
|
||||
static constexpr bool is_router_node = true;
|
||||
|
||||
explicit RouterNode(Selector sel, std::size_t fifo_capacity = 5)
|
||||
: selector_(std::move(sel))
|
||||
, fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
input_ch_ = std::make_shared<Channel<T>>(fifo_capacity);
|
||||
}
|
||||
|
||||
~RouterNode() override { stop(); }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void start() override {
|
||||
input_ch_->enable();
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
input_ch_->disable();
|
||||
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
||||
}
|
||||
|
||||
bool running() const override {
|
||||
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms + blocked_ms;
|
||||
return {name, frames, exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
blocked_ms,
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0};
|
||||
}
|
||||
|
||||
// ── Port access ───────────────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I = 0>
|
||||
InputPort<RouterNode, I> input() {
|
||||
static_assert(I == 0, "RouterNode has exactly one input");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
OutputPort<RouterNode, I> output() {
|
||||
static_assert(I < N, "RouterNode output index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
// ── Internal channel accessors (called by Network::connect) ───────────────
|
||||
|
||||
template<std::size_t I>
|
||||
Channel<T>& input_channel() {
|
||||
static_assert(I == 0);
|
||||
return *input_ch_;
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_input_channel(std::shared_ptr<Channel<T>> ch) {
|
||||
static_assert(I == 0);
|
||||
input_ch_ = std::move(ch);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_channel(Channel<T>* ch) {
|
||||
static_assert(I < N);
|
||||
out_channels_[I] = ch;
|
||||
}
|
||||
|
||||
private:
|
||||
void run_loop() {
|
||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
auto t0 = clock_t::now();
|
||||
T val = input_ch_->pop();
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
std::size_t idx = selector_(val);
|
||||
if (idx < N && out_channels_[idx]) {
|
||||
try { out_channels_[idx]->push(val); }
|
||||
catch (const ChannelOverflowError&) {}
|
||||
}
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
Selector selector_;
|
||||
std::shared_ptr<Channel<T>> input_ch_;
|
||||
std::array<Channel<T>*, N> out_channels_{};
|
||||
std::atomic<bool> stop_flag_{false};
|
||||
std::jthread thread_;
|
||||
NodeStats stats_;
|
||||
};
|
||||
|
||||
// ── FilterNode ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reads one item and pushes it downstream only when pred(item) is true.
|
||||
// Dropped items are not counted as processed frames.
|
||||
//
|
||||
// Usage:
|
||||
// auto filt = make_filter<Frame>([](const Frame& f) { return f.valid; });
|
||||
// net.connect("src", src.output<0>(), "filt", filt.input<0>())
|
||||
// .connect("filt", filt.output<0>(), "dst", dst.input<0>());
|
||||
|
||||
template<typename T, std::size_t Id = 0>
|
||||
class FilterNode : public INode {
|
||||
public:
|
||||
using Predicate = std::function<bool(const T&)>;
|
||||
using args_tuple = std::tuple<T>;
|
||||
using return_tuple = std::tuple<T>;
|
||||
using return_raw = return_tuple;
|
||||
|
||||
static constexpr std::size_t input_count = 1;
|
||||
static constexpr std::size_t output_count = 1;
|
||||
static constexpr std::size_t unique_tag = Id;
|
||||
static constexpr bool is_filter_node = true;
|
||||
|
||||
explicit FilterNode(Predicate pred, std::size_t fifo_capacity = 5)
|
||||
: pred_(std::move(pred))
|
||||
, fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
input_ch_ = std::make_shared<Channel<T>>(fifo_capacity);
|
||||
}
|
||||
|
||||
~FilterNode() override { stop(); }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void start() override {
|
||||
input_ch_->enable();
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
input_ch_->disable();
|
||||
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
||||
}
|
||||
|
||||
bool running() const override {
|
||||
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms + blocked_ms;
|
||||
return {name, frames, exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
blocked_ms,
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0};
|
||||
}
|
||||
|
||||
// ── Port access ───────────────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I = 0>
|
||||
InputPort<FilterNode, I> input() {
|
||||
static_assert(I == 0, "FilterNode has exactly one input");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<std::size_t I = 0>
|
||||
OutputPort<FilterNode, I> output() {
|
||||
static_assert(I == 0, "FilterNode has exactly one output");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
// ── Internal channel accessors (called by Network::connect) ───────────────
|
||||
|
||||
template<std::size_t I>
|
||||
Channel<T>& input_channel() {
|
||||
static_assert(I == 0);
|
||||
return *input_ch_;
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_input_channel(std::shared_ptr<Channel<T>> ch) {
|
||||
static_assert(I == 0);
|
||||
input_ch_ = std::move(ch);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_channel(Channel<T>* ch) {
|
||||
static_assert(I == 0);
|
||||
out_ch_ = ch;
|
||||
}
|
||||
|
||||
private:
|
||||
void run_loop() {
|
||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
auto t0 = clock_t::now();
|
||||
T val = input_ch_->pop();
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
if (pred_(val) && out_ch_) {
|
||||
try { out_ch_->push(val); }
|
||||
catch (const ChannelOverflowError&) {}
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
}
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
Predicate pred_;
|
||||
std::shared_ptr<Channel<T>> input_ch_;
|
||||
Channel<T>* out_ch_{nullptr};
|
||||
std::atomic<bool> stop_flag_{false};
|
||||
std::jthread thread_;
|
||||
NodeStats stats_;
|
||||
};
|
||||
|
||||
// ── Factories ─────────────────────────────────────────────────────────────────
|
||||
|
||||
template<typename T, std::size_t N>
|
||||
RouterNode<T, N> make_router(std::function<std::size_t(const T&)> sel,
|
||||
std::size_t capacity = 5) {
|
||||
return RouterNode<T, N, 0>(std::move(sel), capacity);
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
FilterNode<T> make_filter(std::function<bool(const T&)> pred,
|
||||
std::size_t capacity = 5) {
|
||||
return FilterNode<T, 0>(std::move(pred), capacity);
|
||||
}
|
||||
|
||||
} // namespace kpn
|
||||
+158
-55
@@ -2,16 +2,30 @@
|
||||
#include "diagnostics.hpp"
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <type_traits>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── Data size trait ───────────────────────────────────────────────────────────
|
||||
// Returns the number of bytes of logical payload carried by a value.
|
||||
// Defaults to sizeof(T), which is correct for PODs and fixed-size types.
|
||||
// Specialize for heap-owning types (e.g. cv::Mat) to get accurate bandwidth:
|
||||
//
|
||||
// template<> struct kpn::ChannelDataSize<cv::Mat> {
|
||||
// static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
|
||||
// };
|
||||
|
||||
template<typename T>
|
||||
struct ChannelDataSize {
|
||||
static std::size_t bytes(const T&) { return sizeof(T); }
|
||||
};
|
||||
|
||||
// ── Storage policy ────────────────────────────────────────────────────────────
|
||||
|
||||
template<typename T>
|
||||
@@ -44,67 +58,143 @@ public:
|
||||
ChannelClosedError() : std::runtime_error("channel closed") {}
|
||||
};
|
||||
|
||||
// ── CPU pause hint ────────────────────────────────────────────────────────────
|
||||
// Signals the CPU that this is a spin-wait loop, improving HT sibling throughput
|
||||
// and preventing branch-predictor thrash on x86. Falls back to a compiler barrier.
|
||||
|
||||
[[maybe_unused]] static void spin_hint() noexcept {
|
||||
#if defined(__x86_64__) || defined(__i386__)
|
||||
__asm__ volatile("pause" ::: "memory");
|
||||
#elif defined(__aarch64__) || defined(__arm__)
|
||||
__asm__ volatile("yield" ::: "memory");
|
||||
#else
|
||||
std::atomic_signal_fence(std::memory_order_seq_cst);
|
||||
#endif
|
||||
}
|
||||
|
||||
// ── Channel ───────────────────────────────────────────────────────────────────
|
||||
// SPSC ring buffer with atomic wait/notify and configurable spin-before-sleep.
|
||||
//
|
||||
// `spin_count` (constructor arg, default 200): number of pause-hint iterations
|
||||
// before falling back to atomic::wait (futex). At ~20 ns/pause on x86 this is
|
||||
// ~4 µs. Set to 0 to disable spinning (useful for power-constrained or
|
||||
// predominantly-idle pipelines).
|
||||
//
|
||||
// Memory ordering contract (SPSC):
|
||||
// push(): tail_.store(release) pairs with pop()'s tail_.load(acquire)
|
||||
// head_.load(acquire) pairs with pop()'s head_.store(release)
|
||||
// pop(): head_.store(release) pairs with push()'s head_.load(acquire)
|
||||
// tail_.load(acquire) pairs with push()'s tail_.store(release)
|
||||
|
||||
template<typename T>
|
||||
class Channel {
|
||||
public:
|
||||
using storage_type = channel_storage_t<T>;
|
||||
|
||||
explicit Channel(std::size_t capacity = 5) : capacity_(capacity) {}
|
||||
explicit Channel(std::size_t capacity = 5, std::size_t spin_count = 200)
|
||||
: capacity_(capacity), spin_count_(spin_count)
|
||||
{
|
||||
std::size_t rs = 1;
|
||||
while (rs <= capacity) rs <<= 1; // smallest power-of-2 > capacity
|
||||
ring_mask_ = rs - 1;
|
||||
buf_ = std::make_unique<storage_type[]>(rs);
|
||||
}
|
||||
|
||||
Channel(const Channel&) = delete;
|
||||
Channel& operator=(const Channel&) = delete;
|
||||
|
||||
// Push a value.
|
||||
// - If channel is disabled (accepting_ == false): silently drop, return immediately.
|
||||
// - If channel is full: throw ChannelOverflowError.
|
||||
// - If channel is disabled (accepting_ == false): silently drop.
|
||||
// - If channel is full (fill >= capacity_): throw ChannelOverflowError.
|
||||
void push(T value) {
|
||||
if (!accepting_.load(std::memory_order_relaxed)) {
|
||||
stats_.record_drop();
|
||||
return;
|
||||
}
|
||||
std::unique_lock lock(mutex_);
|
||||
if (!accepting_.load(std::memory_order_relaxed)) {
|
||||
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
|
||||
const std::size_t t = tail_.load(std::memory_order_relaxed);
|
||||
const std::size_t h = head_.load(std::memory_order_acquire);
|
||||
|
||||
if (!accepting_.load(std::memory_order_acquire)) {
|
||||
stats_.record_drop();
|
||||
return;
|
||||
}
|
||||
if (queue_.size() >= capacity_) {
|
||||
if (t - h >= capacity_) {
|
||||
stats_.record_overflow();
|
||||
throw ChannelOverflowError(capacity_);
|
||||
}
|
||||
queue_.push(make_storage(std::move(value)));
|
||||
stats_.record_push(queue_.size());
|
||||
lock.unlock();
|
||||
cv_.notify_one();
|
||||
|
||||
const bool was_empty = (t == h);
|
||||
buf_[t & ring_mask_] = make_storage(std::move(value));
|
||||
tail_.store(t + 1, std::memory_order_release);
|
||||
stats_.record_push(t - h + 1, data_bytes);
|
||||
|
||||
wake_.fetch_add(1, std::memory_order_release);
|
||||
wake_.notify_one();
|
||||
|
||||
if (was_empty && push_callback_)
|
||||
push_callback_();
|
||||
}
|
||||
|
||||
// Blocking pop. Unblocks when an item is available or the channel is disabled.
|
||||
// Throws ChannelClosedError if disabled and queue is empty.
|
||||
// Blocking pop. Returns when an item is available.
|
||||
// Throws ChannelClosedError if the channel is disabled (regardless of fill).
|
||||
T pop() {
|
||||
std::unique_lock lock(mutex_);
|
||||
cv_.wait(lock, [this] {
|
||||
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
|
||||
});
|
||||
if (queue_.empty())
|
||||
throw ChannelClosedError{};
|
||||
T value = extract(std::move(queue_.front()));
|
||||
queue_.pop();
|
||||
stats_.record_pop();
|
||||
return value;
|
||||
for (;;) {
|
||||
// Snapshot wake_ BEFORE reading tail_ to prevent lost wakeups.
|
||||
const uint32_t w = wake_.load(std::memory_order_relaxed);
|
||||
const std::size_t h = head_.load(std::memory_order_relaxed);
|
||||
std::size_t t = tail_.load(std::memory_order_acquire);
|
||||
|
||||
// If empty, spin before sleeping: avoids the futex when the next item
|
||||
// arrives within the spin window (~4 µs at default spin_count=200 on x86).
|
||||
if (h == t) {
|
||||
if (!accepting_.load(std::memory_order_acquire))
|
||||
throw ChannelClosedError{};
|
||||
|
||||
for (std::size_t s = 0; s < spin_count_; ++s) {
|
||||
spin_hint();
|
||||
t = tail_.load(std::memory_order_acquire);
|
||||
if (t != h) break;
|
||||
if (!accepting_.load(std::memory_order_relaxed))
|
||||
throw ChannelClosedError{};
|
||||
}
|
||||
|
||||
if (h == t) {
|
||||
// Still empty after spin — sleep until push() or disable() fires.
|
||||
// Re-check tail after loading w to guard against a lost wakeup.
|
||||
if (tail_.load(std::memory_order_acquire) != h) continue;
|
||||
wake_.wait(w, std::memory_order_relaxed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Item available (found immediately or during spin).
|
||||
if (!accepting_.load(std::memory_order_acquire))
|
||||
throw ChannelClosedError{};
|
||||
T value = extract(std::move(buf_[h & ring_mask_]));
|
||||
head_.store(h + 1, std::memory_order_release);
|
||||
stats_.record_pop();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-blocking pop with timeout. For watchdog/display use only — not used in run_loop.
|
||||
// Non-blocking pop with timeout. For watchdog/display use only.
|
||||
bool try_pop(T& out, std::chrono::milliseconds timeout) {
|
||||
std::unique_lock lock(mutex_);
|
||||
if (!cv_.wait_for(lock, timeout, [this] {
|
||||
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
|
||||
}))
|
||||
return false;
|
||||
if (queue_.empty())
|
||||
return false;
|
||||
out = extract(std::move(queue_.front()));
|
||||
queue_.pop();
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
for (;;) {
|
||||
if (try_pop_now(out)) return true;
|
||||
if (!accepting_.load(std::memory_order_relaxed)) return false;
|
||||
if (std::chrono::steady_clock::now() >= deadline) return false;
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(50));
|
||||
}
|
||||
}
|
||||
|
||||
// Immediate non-blocking pop. Returns false if the ring is empty.
|
||||
bool try_pop_now(T& out) {
|
||||
const std::size_t h = head_.load(std::memory_order_relaxed);
|
||||
if (h == tail_.load(std::memory_order_acquire)) return false;
|
||||
out = extract(std::move(buf_[h & ring_mask_]));
|
||||
head_.store(h + 1, std::memory_order_release);
|
||||
stats_.record_pop();
|
||||
return true;
|
||||
}
|
||||
@@ -114,38 +204,44 @@ public:
|
||||
accepting_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// Disable the channel: drop all queued items, unblock any waiting pop().
|
||||
// Called by consumer node on stop(). Producer push() will silently drop after this.
|
||||
// Disable the channel: stop accepting new pushes, unblock any waiting pop().
|
||||
// Items already in the ring are abandoned and freed when the Channel is destroyed.
|
||||
void disable() {
|
||||
accepting_.store(false, std::memory_order_relaxed);
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
while (!queue_.empty()) queue_.pop();
|
||||
}
|
||||
cv_.notify_all();
|
||||
accepting_.store(false, std::memory_order_release);
|
||||
wake_.fetch_add(1, std::memory_order_release);
|
||||
wake_.notify_all();
|
||||
}
|
||||
|
||||
std::size_t size() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return queue_.size();
|
||||
// Register a callback fired when the queue transitions empty→non-empty.
|
||||
void set_push_callback(std::function<void()> cb) {
|
||||
push_callback_ = std::move(cb);
|
||||
}
|
||||
|
||||
// Size derived lazily from ring indices — no separate counter on the hot path.
|
||||
std::size_t size() const {
|
||||
return tail_.load(std::memory_order_relaxed)
|
||||
- head_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::size_t approx_size() const { return size(); }
|
||||
|
||||
std::size_t capacity() const { return capacity_; }
|
||||
bool is_accepting() const { return accepting_.load(std::memory_order_relaxed); }
|
||||
const ChannelStats& stats() const { return stats_; }
|
||||
|
||||
ChannelSnapshot snapshot(const std::string& name) const {
|
||||
std::lock_guard lock(mutex_);
|
||||
const std::size_t t = tail_.load(std::memory_order_relaxed);
|
||||
const std::size_t h = head_.load(std::memory_order_relaxed);
|
||||
return {
|
||||
name,
|
||||
capacity_,
|
||||
queue_.size(),
|
||||
t - h,
|
||||
stats_.peak_fill.load(std::memory_order_relaxed),
|
||||
stats_.pushes.load(std::memory_order_relaxed),
|
||||
stats_.bytes_pushed.load(std::memory_order_relaxed),
|
||||
stats_.drops.load(std::memory_order_relaxed),
|
||||
stats_.overflows.load(std::memory_order_relaxed),
|
||||
stats_.pops.load(std::memory_order_relaxed),
|
||||
sizeof(T), // payload bytes — sizeof(T) regardless of storage policy
|
||||
sizeof(T),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,12 +260,19 @@ private:
|
||||
return *s;
|
||||
}
|
||||
|
||||
const std::size_t capacity_;
|
||||
std::queue<storage_type> queue_;
|
||||
std::atomic<bool> accepting_{true};
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
ChannelStats stats_;
|
||||
const std::size_t capacity_;
|
||||
const std::size_t spin_count_;
|
||||
std::size_t ring_mask_;
|
||||
std::unique_ptr<storage_type[]> buf_;
|
||||
std::function<void()> push_callback_;
|
||||
ChannelStats stats_;
|
||||
|
||||
// Separate cache lines: head_ is written only by the consumer;
|
||||
// tail_ and wake_ are written only by the producer.
|
||||
alignas(64) std::atomic<std::size_t> head_{0};
|
||||
alignas(64) std::atomic<std::size_t> tail_{0};
|
||||
std::atomic<uint32_t> wake_{0};
|
||||
std::atomic<bool> accepting_{true};
|
||||
};
|
||||
|
||||
// ── Channel probe — type-erased snapshot accessor ─────────────────────────────
|
||||
|
||||
@@ -15,6 +15,7 @@ using duration_t = std::chrono::duration<double, std::milli>; // milliseconds
|
||||
|
||||
struct ChannelStats {
|
||||
std::atomic<uint64_t> pushes{0};
|
||||
std::atomic<uint64_t> bytes_pushed{0};
|
||||
std::atomic<uint64_t> drops{0};
|
||||
std::atomic<uint64_t> overflows{0};
|
||||
std::atomic<uint64_t> pops{0};
|
||||
@@ -24,8 +25,9 @@ struct ChannelStats {
|
||||
ChannelStats(const ChannelStats&) = delete;
|
||||
ChannelStats& operator=(const ChannelStats&) = delete;
|
||||
|
||||
void record_push(std::size_t current_fill) {
|
||||
void record_push(std::size_t current_fill, std::size_t data_bytes) {
|
||||
pushes.fetch_add(1, std::memory_order_relaxed);
|
||||
bytes_pushed.fetch_add(data_bytes, std::memory_order_relaxed);
|
||||
std::size_t prev = peak_fill.load(std::memory_order_relaxed);
|
||||
while (current_fill > prev &&
|
||||
!peak_fill.compare_exchange_weak(prev, current_fill,
|
||||
@@ -54,6 +56,12 @@ struct NodeStats {
|
||||
// blocked on mutexes/channels. Sampled once per frame.
|
||||
std::atomic<int64_t> total_cpu_us{0}; // cumulative CPU µs consumed
|
||||
|
||||
// Pool scheduling stats — only meaningful for PoolNode / InterruptNode.
|
||||
// exec_start_us: wall-clock µs when fire_once began; 0 when idle.
|
||||
// Used by the watchdog to detect hung nodes (elapsed > max_exec_time).
|
||||
std::atomic<int64_t> queue_wait_us{0}; // cumulative µs spent in pool queue
|
||||
std::atomic<int64_t> exec_start_us{0}; // non-zero while fire_once is running
|
||||
|
||||
NodeStats() = default;
|
||||
NodeStats(const NodeStats&) = delete;
|
||||
NodeStats& operator=(const NodeStats&) = delete;
|
||||
@@ -71,6 +79,11 @@ struct NodeStats {
|
||||
+ static_cast<int64_t>(ts.tv_nsec) / 1'000;
|
||||
}
|
||||
|
||||
void record_queue_wait(duration_t wait) {
|
||||
int64_t us = static_cast<int64_t>(wait.count() * 1000.0);
|
||||
if (us > 0) queue_wait_us.fetch_add(us, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void record_exec(duration_t exec_time, duration_t blocked_time,
|
||||
const struct timespec& cpu_before, const struct timespec& cpu_after) {
|
||||
frames_processed.fetch_add(1, std::memory_order_relaxed);
|
||||
@@ -105,10 +118,11 @@ struct ChannelSnapshot {
|
||||
std::size_t current_fill;
|
||||
std::size_t peak_fill;
|
||||
uint64_t pushes;
|
||||
uint64_t bytes_pushed; // actual bytes accumulated via channel_data_size<T>
|
||||
uint64_t drops;
|
||||
uint64_t overflows;
|
||||
uint64_t pops;
|
||||
std::size_t item_bytes; // sizeof(T) for the stored type — set by Channel<T>
|
||||
std::size_t item_bytes; // sizeof(T) — nominal struct size, not necessarily data size
|
||||
|
||||
double fill_pct() const {
|
||||
return capacity ? 100.0 * current_fill / capacity : 0.0;
|
||||
@@ -116,10 +130,10 @@ struct ChannelSnapshot {
|
||||
double peak_pct() const {
|
||||
return capacity ? 100.0 * peak_fill / capacity : 0.0;
|
||||
}
|
||||
// Bandwidth in MB/s: bytes transferred / elapsed seconds
|
||||
// Bandwidth in MB/s: actual bytes transferred / elapsed seconds
|
||||
double bandwidth_mbs(double elapsed_s) const {
|
||||
if (elapsed_s <= 0.0 || item_bytes == 0) return 0.0;
|
||||
return static_cast<double>(pushes * item_bytes) / elapsed_s / 1e6;
|
||||
if (elapsed_s <= 0.0) return 0.0;
|
||||
return static_cast<double>(bytes_pushed) / elapsed_s / 1e6;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,10 +142,27 @@ struct NodeSnapshot {
|
||||
uint64_t frames_processed;
|
||||
double ema_exec_ms;
|
||||
double max_exec_ms;
|
||||
double total_blocked_ms;
|
||||
double total_blocked_ms; // ThreadPerNode: time blocked in channel pop
|
||||
double throughput_fps;
|
||||
double total_cpu_ms; // cumulative CPU time consumed by this node's thread
|
||||
double cpu_util_pct; // exec_ms / (exec_ms + blocked_ms) * 100
|
||||
double total_cpu_ms; // cumulative CPU time consumed by this node's thread
|
||||
double cpu_util_pct; // exec_ms / (exec_ms + blocked_ms) * 100
|
||||
double queue_wait_ms{0}; // PoolNode: cumulative time spent in pool queue
|
||||
};
|
||||
|
||||
// ── Pool statistics + snapshot ────────────────────────────────────────────────
|
||||
|
||||
struct PoolSnapshot {
|
||||
std::string name;
|
||||
std::size_t thread_count;
|
||||
std::size_t queue_depth; // tasks waiting in the priority queue
|
||||
std::size_t active_count; // tasks currently executing
|
||||
uint64_t tasks_submitted;
|
||||
uint64_t tasks_completed;
|
||||
};
|
||||
|
||||
struct IPoolProbe {
|
||||
virtual ~IPoolProbe() = default;
|
||||
virtual PoolSnapshot snapshot(const std::string& name) const = 0;
|
||||
};
|
||||
|
||||
// ── Cross-network snapshot (used by DebugHub) ─────────────────────────────────
|
||||
|
||||
+2
-20
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "node.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
#include "traits.hpp"
|
||||
|
||||
@@ -15,24 +15,6 @@
|
||||
|
||||
namespace kpn {
|
||||
|
||||
namespace detail {
|
||||
|
||||
// Produces std::tuple<T, T, ..., T> with N repetitions — used so that
|
||||
// Network::connect can do its normal return_tuple type-check against FanoutNode.
|
||||
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
|
||||
struct repeat_tuple;
|
||||
|
||||
template<typename T, std::size_t N, std::size_t... Is>
|
||||
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
|
||||
template<std::size_t> using always_T = T;
|
||||
using type = std::tuple<always_T<Is>...>;
|
||||
};
|
||||
|
||||
template<typename T, std::size_t N>
|
||||
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ── FanoutNode ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reads one item from its single input channel and pushes a copy to each of
|
||||
@@ -48,7 +30,7 @@ template<typename T, std::size_t N, std::size_t Id = 0>
|
||||
class FanoutNode : public INode {
|
||||
public:
|
||||
using args_tuple = std::tuple<T>;
|
||||
using return_tuple = detail::repeat_tuple_t<T, N>;
|
||||
using return_tuple = repeat_tuple_t<T, N>;
|
||||
using return_raw = return_tuple;
|
||||
|
||||
static constexpr std::size_t input_count = 1;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include "diagnostics.hpp"
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// Called when a node's function throws. Return true to skip the failed
|
||||
// invocation and keep running, false to stop the node.
|
||||
using NodeErrorHandler = std::function<bool(std::string_view node_name, std::exception_ptr)>;
|
||||
|
||||
// ── INode — type-erased interface for Network / watchdog ─────────────────────
|
||||
|
||||
struct INode {
|
||||
virtual ~INode() = default;
|
||||
virtual void start() = 0;
|
||||
virtual void stop() = 0;
|
||||
virtual bool running() const = 0;
|
||||
virtual const NodeStats& stats() const = 0;
|
||||
virtual NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const = 0;
|
||||
virtual void set_name(std::string name) = 0;
|
||||
|
||||
// halt(): alias for stop() — immediate, discards in-flight work.
|
||||
virtual void halt() { stop(); }
|
||||
|
||||
// shutdown(): graceful drain before stopping. Base implementation falls
|
||||
// back to stop(). Network and StaticNetwork override with topo-ordered drain.
|
||||
virtual void shutdown() { stop(); }
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
@@ -0,0 +1,259 @@
|
||||
#pragma once
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "fixed_string.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
#include "scheduler.hpp"
|
||||
#include "traits.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── InterruptNode ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A source node (zero inputs) driven by an external event — camera frame ready,
|
||||
// timer tick, socket data, etc. — rather than self-submission.
|
||||
//
|
||||
// Usage:
|
||||
// auto node = make_interrupt_node<produce_frame>(pool, out<"frame">{});
|
||||
// camera_sdk.on_frame_ready(node.get_trigger()); // register with external source
|
||||
// network.add("camera", node).connect(...).build().start();
|
||||
//
|
||||
// The trigger callable is safe to call from any thread, including signal handlers,
|
||||
// provided the underlying scheduler's submit() is signal-safe. After fire_once()
|
||||
// completes the node is idle until the next trigger fires — it does NOT busy-loop.
|
||||
|
||||
template<auto Func,
|
||||
typename OutputTag = out<>,
|
||||
fixed_string Label = "",
|
||||
std::size_t UniqueTag = 0>
|
||||
class InterruptNode;
|
||||
|
||||
template<auto Func, fixed_string... OutNames, fixed_string Label, std::size_t UniqueTag>
|
||||
class InterruptNode<Func, out<OutNames...>, Label, UniqueTag> : public INode {
|
||||
public:
|
||||
using F = decltype(Func);
|
||||
using return_raw = return_t<F>;
|
||||
using return_tuple = normalised_return_t<return_raw>;
|
||||
|
||||
static_assert(arity_v<F> == 0,
|
||||
"InterruptNode function must take no arguments (it has no input channels)");
|
||||
|
||||
static constexpr std::string_view label() { return Label.view(); }
|
||||
static constexpr std::size_t unique_tag = UniqueTag;
|
||||
static constexpr std::size_t input_count = 0;
|
||||
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
||||
|
||||
static_assert(
|
||||
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
||||
"make_interrupt_node: number of output names must match return tuple size, or provide none"
|
||||
);
|
||||
|
||||
explicit InterruptNode(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5)
|
||||
: scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
|
||||
{}
|
||||
|
||||
~InterruptNode() override { stop(); }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void start() override {
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
pending_.store(0, std::memory_order_relaxed);
|
||||
// Does NOT self-submit — waits for first external trigger.
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_seq_cst);
|
||||
// In-flight fire_once() observes stop_flag_ on its next check.
|
||||
}
|
||||
|
||||
bool running() const override { return !stop_flag_.load(std::memory_order_relaxed); }
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double qwait_ms = stats_.queue_wait_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms; // no blocked time for interrupt nodes
|
||||
return {
|
||||
name, frames, exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
0.0, // blocked_ms — not applicable
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 : 0.0,
|
||||
qwait_ms,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Port access — by index ────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I>
|
||||
OutputPort<InterruptNode, I> output() {
|
||||
static_assert(I < output_count, "output index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<fixed_string Name>
|
||||
auto output() {
|
||||
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
||||
static_assert(idx != npos, "unknown output port name");
|
||||
return output<idx>();
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
||||
std::get<I>(output_channels_) = ch;
|
||||
}
|
||||
|
||||
// ── Trigger ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Returns a callable that fires this node when called.
|
||||
// Pass it to a camera SDK, timer, or any external event source.
|
||||
// Thread-safe; may be called from any thread.
|
||||
std::function<void()> get_trigger() {
|
||||
return [this] { trigger(); };
|
||||
}
|
||||
|
||||
private:
|
||||
// Each trigger() increments pending_. When going 0→1 a task is submitted.
|
||||
// Each fire_once() handles one pending event and decrements; if more remain
|
||||
// (old value > 1) it resubmits itself. This guarantees every trigger produces
|
||||
// exactly one execution even if triggers arrive faster than fire_once completes.
|
||||
void trigger() {
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||
if (pending_.fetch_add(1, std::memory_order_acq_rel) == 0)
|
||||
scheduler_->submit([this] { fire_once(); });
|
||||
}
|
||||
|
||||
void fire_once() {
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||
pending_.store(0, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
|
||||
auto t0 = clock_t::now();
|
||||
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
t0.time_since_epoch()).count();
|
||||
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
||||
|
||||
bool fatal = false;
|
||||
try {
|
||||
auto t1 = clock_t::now();
|
||||
stats_.record_queue_wait(duration_t(t1 - t0));
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
if constexpr (std::is_void_v<return_raw>) {
|
||||
Func();
|
||||
} else {
|
||||
auto result = Func();
|
||||
push_outputs(normalise(std::move(result)),
|
||||
std::make_index_sequence<output_count>{});
|
||||
}
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||
} catch (const ChannelOverflowError& e) {
|
||||
std::cerr << "[kpn] interrupt node overflow: " << e.what() << "\n";
|
||||
} catch (...) {
|
||||
if (!error_handler_ || !error_handler_(name_, std::current_exception()))
|
||||
fatal = true;
|
||||
}
|
||||
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
|
||||
if (fatal) {
|
||||
pending_.store(0, std::memory_order_release);
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrement and resubmit only if more triggers are queued.
|
||||
// fetch_sub returns old value; old > 1 means new > 0.
|
||||
if (pending_.fetch_sub(1, std::memory_order_acq_rel) > 1)
|
||||
scheduler_->submit([this] { fire_once(); });
|
||||
}
|
||||
|
||||
template<typename R = return_raw>
|
||||
static return_tuple normalise(R&& r) {
|
||||
if constexpr (is_tuple_v<R>) return std::move(r);
|
||||
else return std::make_tuple(std::move(r));
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||
(push_one<Is>(std::get<Is>(std::move(result))), ...);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
|
||||
auto* ch = std::get<I>(output_channels_);
|
||||
if (!ch) return;
|
||||
try { ch->push(std::move(val)); }
|
||||
catch (const ChannelOverflowError&) {
|
||||
throw ChannelOverflowError(ch->capacity(),
|
||||
"interrupt node '" + name_ + "' " + output_port_label<I>());
|
||||
}
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
static std::string output_port_label() {
|
||||
if constexpr (sizeof...(OutNames) > 0) {
|
||||
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
|
||||
return std::string("output['") + std::string(names[I]) + "']";
|
||||
} else {
|
||||
return "output[" + std::to_string(I) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
||||
|
||||
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||
std::make_index_sequence<output_count>{}));
|
||||
|
||||
std::shared_ptr<IScheduler> scheduler_;
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
output_channels_t output_channels_{};
|
||||
std::atomic<bool> stop_flag_{true};
|
||||
std::atomic<int> pending_{0}; // triggers awaiting execution
|
||||
NodeStats stats_;
|
||||
NodeErrorHandler error_handler_;
|
||||
std::chrono::milliseconds max_exec_time_{0};
|
||||
};
|
||||
|
||||
// ── make_interrupt_node factory ───────────────────────────────────────────────
|
||||
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
|
||||
auto make_interrupt_node(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5) {
|
||||
return InterruptNode<Func, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||
fixed_string... OutNames>
|
||||
auto make_interrupt_node(std::shared_ptr<IScheduler> sched, out<OutNames...>,
|
||||
std::size_t fifo_capacity = 5) {
|
||||
return InterruptNode<Func, out<OutNames...>, Label, UniqueTag>(
|
||||
std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
} // namespace kpn
|
||||
@@ -5,8 +5,13 @@
|
||||
#include "traits.hpp"
|
||||
#include "channel.hpp"
|
||||
#include "port.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "scheduler.hpp"
|
||||
#include "pool_node.hpp"
|
||||
#include "interrupt_node.hpp"
|
||||
#include "node.hpp"
|
||||
#include "fanout.hpp"
|
||||
#include "branch.hpp"
|
||||
#include "shared_resource.hpp"
|
||||
#include "static_network.hpp"
|
||||
#include "debug_hub.hpp"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "fixed_string.hpp"
|
||||
#include "node.hpp" // INode
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
+118
-6
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
#include "diagnostics.hpp"
|
||||
#include "node.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
@@ -126,14 +126,17 @@ public:
|
||||
web_debug_port_,
|
||||
[this]() {
|
||||
auto s = collect_snapshots();
|
||||
return web_debug::to_json(s.nodes, s.channels, {}, s.elapsed_s);
|
||||
return web_debug::to_json(s.nodes, s.channels, {}, s.elapsed_s, s.pools);
|
||||
});
|
||||
web_server_->start();
|
||||
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
|
||||
#endif
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
void stop() override { halt(); }
|
||||
|
||||
// halt(): immediate stop — broadcasts disable to all channels and joins threads.
|
||||
void halt() override {
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_) web_server_->stop();
|
||||
#endif
|
||||
@@ -142,6 +145,37 @@ public:
|
||||
nodes_.at(*it)->stop();
|
||||
}
|
||||
|
||||
// shutdown(): graceful drain in topological order.
|
||||
// Stops source nodes first, polls until their output channels drain to zero,
|
||||
// then stops the next layer, and so on.
|
||||
void shutdown() override {
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_) web_server_->stop();
|
||||
#endif
|
||||
stop_watchdog();
|
||||
|
||||
// Identify which nodes have no incoming edges (sources).
|
||||
std::map<std::string, std::size_t> in_degree;
|
||||
for (auto& [name, _] : nodes_) in_degree[name] = 0;
|
||||
for (auto& [src, dsts] : adj_)
|
||||
for (auto& dst : dsts) in_degree[dst]++;
|
||||
|
||||
// Walk topo order: stop each source layer, wait for its output channels
|
||||
// to drain, then proceed to the next layer.
|
||||
std::set<std::string> stopped;
|
||||
for (auto& name : topo_) {
|
||||
if (in_degree[name] == 0 || all_predecessors_stopped(name, stopped)) {
|
||||
nodes_.at(name)->stop();
|
||||
stopped.insert(name);
|
||||
// Wait for output channels of this node to drain.
|
||||
drain_output_channels(name);
|
||||
}
|
||||
}
|
||||
// Stop any remaining nodes (sinks / nodes not yet stopped).
|
||||
for (auto it = topo_.rbegin(); it != topo_.rend(); ++it)
|
||||
if (!stopped.count(*it)) nodes_.at(*it)->stop();
|
||||
}
|
||||
|
||||
bool running() const override { return watchdog_.joinable(); }
|
||||
void set_name(std::string) override {}
|
||||
|
||||
@@ -163,6 +197,10 @@ public:
|
||||
void set_error_handler(ErrorHandler h) { error_handler_ = std::move(h); }
|
||||
void set_diagnostics_handler(DiagnosticsHandler h) { diag_handler_ = std::move(h); }
|
||||
|
||||
void register_pool(const std::string& name, IPoolProbe* probe) {
|
||||
pool_probes_.emplace_back(name, probe);
|
||||
}
|
||||
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
|
||||
#endif
|
||||
@@ -171,7 +209,7 @@ public:
|
||||
// Can be called at any time; thread-safe (reads atomics with relaxed ordering).
|
||||
void print_diagnostics(std::ostream& os = std::cerr) const {
|
||||
auto s = collect_snapshots();
|
||||
os << format_report(s.nodes, s.channels, s.elapsed_s);
|
||||
os << format_report(s.nodes, s.channels, s.pools, s.elapsed_s);
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -180,6 +218,7 @@ private:
|
||||
struct Snapshots {
|
||||
std::vector<NodeSnapshot> nodes;
|
||||
std::vector<ChannelSnapshot> channels;
|
||||
std::vector<PoolSnapshot> pools;
|
||||
double elapsed_s;
|
||||
};
|
||||
|
||||
@@ -195,11 +234,16 @@ private:
|
||||
for (auto& probe : channel_probes_)
|
||||
channels.push_back(probe->snapshot());
|
||||
|
||||
return {std::move(nodes), std::move(channels), elapsed_s};
|
||||
std::vector<PoolSnapshot> pools;
|
||||
for (auto& [name, probe] : pool_probes_)
|
||||
pools.push_back(probe->snapshot(name));
|
||||
|
||||
return {std::move(nodes), std::move(channels), std::move(pools), elapsed_s};
|
||||
}
|
||||
|
||||
static std::string format_report(const std::vector<NodeSnapshot>& nodes,
|
||||
const std::vector<ChannelSnapshot>& channels,
|
||||
const std::vector<PoolSnapshot>& pools = {},
|
||||
double elapsed_s = 0.0) {
|
||||
std::ostringstream os;
|
||||
os << std::fixed << std::setprecision(1);
|
||||
@@ -258,6 +302,31 @@ private:
|
||||
<< flag << "\n";
|
||||
}
|
||||
|
||||
// Pool table
|
||||
if (!pools.empty()) {
|
||||
os << "│\n│ Thread Pools:\n";
|
||||
os << "│ " << std::left
|
||||
<< std::setw(16) << "name"
|
||||
<< std::setw(10) << "threads"
|
||||
<< std::setw(12) << "queued"
|
||||
<< std::setw(12) << "active"
|
||||
<< std::setw(14) << "in/s"
|
||||
<< std::setw(14) << "out/s"
|
||||
<< "\n│ " << std::string(78, '-') << "\n";
|
||||
for (auto& p : pools) {
|
||||
double in_rate = elapsed_s > 0.0 ? p.tasks_submitted / elapsed_s : 0.0;
|
||||
double out_rate = elapsed_s > 0.0 ? p.tasks_completed / elapsed_s : 0.0;
|
||||
os << "│ " << std::left
|
||||
<< std::setw(16) << p.name
|
||||
<< std::setw(10) << p.thread_count
|
||||
<< std::setw(12) << p.queue_depth
|
||||
<< std::setw(12) << p.active_count
|
||||
<< std::setw(14) << in_rate
|
||||
<< std::setw(14) << out_rate
|
||||
<< "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Bottleneck hint: node with highest ema_exec_ms
|
||||
if (!nodes.empty()) {
|
||||
auto it = std::max_element(nodes.begin(), nodes.end(),
|
||||
@@ -271,6 +340,31 @@ private:
|
||||
return os.str();
|
||||
}
|
||||
|
||||
// ── Shutdown helpers ──────────────────────────────────────────────────────
|
||||
|
||||
bool all_predecessors_stopped(const std::string& name,
|
||||
const std::set<std::string>& stopped) const {
|
||||
for (auto& [src, dsts] : adj_)
|
||||
for (auto& dst : dsts)
|
||||
if (dst == name && !stopped.count(src)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void drain_output_channels(const std::string& /*name*/) const {
|
||||
// Poll all channel probes until none report non-zero fill.
|
||||
// A short sleep prevents busy-spin; 1 ms is fine for drain purposes.
|
||||
bool any_full = true;
|
||||
while (any_full) {
|
||||
any_full = false;
|
||||
for (auto& probe : channel_probes_) {
|
||||
auto snap = probe->snapshot();
|
||||
if (snap.current_fill > 0) { any_full = true; break; }
|
||||
}
|
||||
if (any_full)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cycle detection / topological sort ───────────────────────────────────
|
||||
|
||||
void dfs(const std::string& name, std::map<std::string, int>& color) {
|
||||
@@ -292,16 +386,33 @@ private:
|
||||
if (tok.stop_requested()) break;
|
||||
|
||||
auto s = collect_snapshots();
|
||||
check_hung_nodes();
|
||||
|
||||
if (diag_handler_) {
|
||||
diag_handler_(s.nodes, s.channels);
|
||||
} else {
|
||||
std::cerr << format_report(s.nodes, s.channels, s.elapsed_s);
|
||||
std::cerr << format_report(s.nodes, s.channels, s.pools, s.elapsed_s);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void check_hung_nodes() const {
|
||||
auto now_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
clock_t::now().time_since_epoch()).count();
|
||||
for (auto& [name, node] : nodes_) {
|
||||
int64_t start = node->stats().exec_start_us.load(std::memory_order_relaxed);
|
||||
if (start == 0) continue;
|
||||
int64_t elapsed_ms = (now_us - start) / 1000;
|
||||
// Warn if a node has been executing for > 5 s with no max_exec_time set,
|
||||
// or if it exceeds its configured max. Threshold: 5000 ms default.
|
||||
if (elapsed_ms > 5000) {
|
||||
std::cerr << "[kpn] WARNING: node '" << name
|
||||
<< "' has been executing for " << elapsed_ms << " ms\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stop_watchdog() {
|
||||
if (watchdog_.joinable())
|
||||
watchdog_.request_stop(), watchdog_.join();
|
||||
@@ -316,6 +427,7 @@ private:
|
||||
std::map<std::string, std::string> exposed_outputs_;
|
||||
std::set<std::pair<std::string, std::size_t>> connected_outputs_;
|
||||
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
|
||||
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
|
||||
ErrorHandler error_handler_;
|
||||
DiagnosticsHandler diag_handler_;
|
||||
std::chrono::milliseconds watchdog_interval_{3000};
|
||||
|
||||
+36
-516
@@ -1,46 +1,29 @@
|
||||
#pragma once
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "fixed_string.hpp"
|
||||
#include "port.hpp"
|
||||
#include "traits.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "pool_node.hpp" // PoolNode, PoolObjectNode
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
// node.hpp — Node<> and ObjectNode<> as thin wrappers over PoolNode<>.
|
||||
//
|
||||
// Each Node owns a private single-thread ThreadPool so the API is unchanged:
|
||||
// node.start() / node.stop() are self-contained with no external scheduler.
|
||||
// Internally, all execution goes through PoolNode::fire_once() — the same code
|
||||
// path as explicitly pool-scheduled nodes.
|
||||
//
|
||||
// To share a thread pool across multiple nodes, use make_pool_node() directly.
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// Called when a node's function throws. Return true to skip the failed
|
||||
// invocation and keep running, false to stop the node.
|
||||
using NodeErrorHandler = std::function<bool(std::string_view node_name, std::exception_ptr)>;
|
||||
namespace detail {
|
||||
|
||||
// ── INode — type-erased interface for Network / watchdog ─────────────────────
|
||||
|
||||
struct INode {
|
||||
virtual ~INode() = default;
|
||||
virtual void start() = 0;
|
||||
virtual void stop() = 0;
|
||||
virtual bool running() const = 0;
|
||||
virtual const NodeStats& stats() const = 0;
|
||||
virtual NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const = 0;
|
||||
virtual void set_name(std::string name) = 0;
|
||||
// Private base initialized before PoolNode so its pool can be passed to the
|
||||
// PoolNode constructor (C++ initialises bases left-to-right).
|
||||
struct NodePrivatePool {
|
||||
std::shared_ptr<ThreadPool> pool{std::make_shared<ThreadPool>(1)};
|
||||
};
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ── Node ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Template parameters:
|
||||
// Func — the wrapped function (auto NTTP, deduced as a function pointer)
|
||||
// InputNames — optional kpn::in<"a","b"> tag type (at most one)
|
||||
// OutputNames — optional kpn::out<"x","y"> tag type (at most one)
|
||||
|
||||
template<auto Func,
|
||||
typename InputTag = in<>,
|
||||
@@ -49,267 +32,25 @@ template<auto Func,
|
||||
std::size_t UniqueTag = 0>
|
||||
class Node;
|
||||
|
||||
// Specialisation that unpacks the in<>/out<> tag packs
|
||||
template<auto Func, fixed_string... InNames, fixed_string... OutNames,
|
||||
fixed_string Label, std::size_t UniqueTag>
|
||||
class Node<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
|
||||
class Node<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>
|
||||
: private detail::NodePrivatePool
|
||||
, public PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
|
||||
using Base = PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
|
||||
public:
|
||||
using F = decltype(Func);
|
||||
using args_tuple = args_t<F>;
|
||||
using return_raw = return_t<F>;
|
||||
using return_tuple = normalised_return_t<return_raw>;
|
||||
|
||||
// Identity accessors — used by StaticNetwork for diagnostics and type-level uniqueness
|
||||
static constexpr std::string_view label() { return Label.view(); }
|
||||
static constexpr std::size_t unique_tag = UniqueTag;
|
||||
|
||||
static constexpr std::size_t input_count = arity_v<F>;
|
||||
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
||||
|
||||
static_assert(
|
||||
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
||||
"make_node: number of input names must match function arity, or provide none"
|
||||
);
|
||||
static_assert(
|
||||
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
||||
"make_node: number of output names must match return tuple size, or provide none"
|
||||
);
|
||||
|
||||
explicit Node(std::size_t fifo_capacity = 5)
|
||||
: fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
init_input_channels(std::make_index_sequence<input_count>{});
|
||||
}
|
||||
: detail::NodePrivatePool{}
|
||||
, Base(pool, fifo_capacity)
|
||||
{}
|
||||
|
||||
~Node() override { stop(); }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void start() override {
|
||||
enable_inputs(std::make_index_sequence<input_count>{});
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
// Disable all input channels: drops queued items and unblocks waiting pop()
|
||||
disable_inputs(std::make_index_sequence<input_count>{});
|
||||
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
||||
}
|
||||
|
||||
bool running() const override {
|
||||
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
|
||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms + blocked_ms;
|
||||
return {
|
||||
name,
|
||||
frames,
|
||||
exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
blocked_ms,
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Port access — by index ────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I>
|
||||
InputPort<Node, I> input() {
|
||||
static_assert(I < input_count, "input index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
OutputPort<Node, I> output() {
|
||||
static_assert(I < output_count, "output index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
// ── Port access — by name ─────────────────────────────────────────────────
|
||||
|
||||
template<fixed_string Name>
|
||||
auto input() {
|
||||
constexpr std::size_t idx = index_of<Name, InNames...>();
|
||||
static_assert(idx != npos, "unknown input port name");
|
||||
return input<idx>();
|
||||
}
|
||||
|
||||
template<fixed_string Name>
|
||||
auto output() {
|
||||
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
||||
static_assert(idx != npos, "unknown output port name");
|
||||
return output<idx>();
|
||||
}
|
||||
|
||||
// ── Internal channel accessors (used by Network at connect time) ──────────
|
||||
|
||||
template<std::size_t I>
|
||||
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
|
||||
return *std::get<I>(input_channels_);
|
||||
}
|
||||
|
||||
// Replace the owned input channel with an externally provided one.
|
||||
// Used by VariantNodeWrapper to share a Channel<T> with a VariantChannel adapter.
|
||||
template<std::size_t I>
|
||||
void set_input_channel(
|
||||
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
|
||||
std::get<I>(input_channels_) = std::move(ch);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_channel(
|
||||
Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
||||
std::get<I>(output_channels_) = ch;
|
||||
}
|
||||
|
||||
private:
|
||||
// ── Channel storage ───────────────────────────────────────────────────────
|
||||
|
||||
// Input channels — shared ownership so VariantChannel adapters can share them
|
||||
template<std::size_t... Is>
|
||||
void init_input_channels(std::index_sequence<Is...>) {
|
||||
((std::get<Is>(input_channels_) =
|
||||
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
|
||||
...);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void enable_inputs(std::index_sequence<Is...>) {
|
||||
(std::get<Is>(input_channels_)->enable(), ...);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void disable_inputs(std::index_sequence<Is...>) {
|
||||
(std::get<Is>(input_channels_)->disable(), ...);
|
||||
}
|
||||
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_input_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
|
||||
|
||||
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
|
||||
std::make_index_sequence<input_count>{}));
|
||||
|
||||
// Output channels — non-owning pointers, set at connect time
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
||||
|
||||
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||
std::make_index_sequence<output_count>{}));
|
||||
|
||||
// ── run_loop ──────────────────────────────────────────────────────────────
|
||||
|
||||
void run_loop() {
|
||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
auto t0 = clock_t::now();
|
||||
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
if constexpr (std::is_void_v<return_raw>) {
|
||||
std::apply(Func, args);
|
||||
} else {
|
||||
auto result = std::apply(Func, args);
|
||||
push_outputs(normalise(std::move(result)),
|
||||
std::make_index_sequence<output_count>{});
|
||||
}
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
} catch (const ChannelOverflowError& e) {
|
||||
std::cerr << "[kpn] overflow: " << e.what() << "\n";
|
||||
} catch (...) {
|
||||
if (error_handler_ && error_handler_(name_, std::current_exception()))
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// Pop all inputs into a tuple of argument values
|
||||
template<std::size_t... Is>
|
||||
args_tuple pop_inputs(std::index_sequence<Is...>) {
|
||||
return {std::get<Is>(input_channels_)->pop()...};
|
||||
}
|
||||
|
||||
// Normalise return value to tuple (handles void and single-value returns)
|
||||
template<typename R = return_raw>
|
||||
static return_tuple normalise(R&& r) {
|
||||
if constexpr (is_tuple_v<R>)
|
||||
return std::move(r);
|
||||
else
|
||||
return std::make_tuple(std::move(r));
|
||||
}
|
||||
|
||||
static return_tuple normalise_void() { return {}; }
|
||||
|
||||
// Push each output element to its connected channel (if connected)
|
||||
template<std::size_t... Is>
|
||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||
(push_one<Is>(std::get<Is>(std::move(result))), ...);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
|
||||
auto* ch = std::get<I>(output_channels_);
|
||||
if (!ch) return;
|
||||
try {
|
||||
ch->push(std::move(val));
|
||||
} catch (const ChannelOverflowError&) {
|
||||
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
|
||||
}
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
static std::string output_port_label() {
|
||||
if constexpr (sizeof...(OutNames) > 0) {
|
||||
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
|
||||
return std::string("output['") + std::string(names[I]) + "']";
|
||||
} else {
|
||||
return "output[" + std::to_string(I) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
input_channels_t input_channels_;
|
||||
output_channels_t output_channels_{};
|
||||
std::atomic<bool> stop_flag_{false};
|
||||
std::jthread thread_;
|
||||
NodeStats stats_;
|
||||
NodeErrorHandler error_handler_;
|
||||
void start() override { pool->start(); Base::start(); }
|
||||
void stop() override { Base::stop(); pool->stop(); }
|
||||
};
|
||||
|
||||
// ── ObjectNode — wraps a callable object (functor / class with operator()) ────
|
||||
//
|
||||
// Use this when the node needs state initialised in a constructor.
|
||||
// The object must outlive the ObjectNode (stored by reference).
|
||||
//
|
||||
// Usage:
|
||||
// MyFunctor obj(...);
|
||||
// auto node = make_node(obj, in<"x">{}, out<"y">{}, capacity);
|
||||
// ── ObjectNode ────────────────────────────────────────────────────────────────
|
||||
|
||||
template<typename Obj,
|
||||
typename InputTag = in<>,
|
||||
@@ -320,230 +61,20 @@ class ObjectNode;
|
||||
|
||||
template<typename Obj, fixed_string... InNames, fixed_string... OutNames,
|
||||
fixed_string Label, std::size_t UniqueTag>
|
||||
class ObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
|
||||
class ObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>
|
||||
: private detail::NodePrivatePool
|
||||
, public PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
|
||||
using Base = PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
|
||||
public:
|
||||
using F = decltype(&Obj::operator());
|
||||
using args_tuple = args_t<F>;
|
||||
using return_raw = return_t<F>;
|
||||
using return_tuple = normalised_return_t<return_raw>;
|
||||
|
||||
static constexpr std::string_view label() { return Label.view(); }
|
||||
static constexpr std::size_t unique_tag = UniqueTag;
|
||||
|
||||
static constexpr std::size_t input_count = arity_v<F>;
|
||||
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
||||
|
||||
static_assert(
|
||||
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
||||
"make_node: number of input names must match operator() arity, or provide none"
|
||||
);
|
||||
static_assert(
|
||||
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
||||
"make_node: number of output names must match return tuple size, or provide none"
|
||||
);
|
||||
|
||||
explicit ObjectNode(Obj& obj, std::size_t fifo_capacity = 5)
|
||||
: obj_(obj), fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
init_input_channels(std::make_index_sequence<input_count>{});
|
||||
}
|
||||
: detail::NodePrivatePool{}
|
||||
, Base(obj, pool, fifo_capacity)
|
||||
{}
|
||||
|
||||
~ObjectNode() override { stop(); }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void start() override {
|
||||
enable_inputs(std::make_index_sequence<input_count>{});
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
disable_inputs(std::make_index_sequence<input_count>{});
|
||||
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
||||
}
|
||||
|
||||
bool running() const override {
|
||||
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
|
||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms + blocked_ms;
|
||||
return {
|
||||
name, frames,
|
||||
exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
blocked_ms,
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Port access ───────────────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I>
|
||||
InputPort<ObjectNode, I> input() {
|
||||
static_assert(I < input_count, "input index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
OutputPort<ObjectNode, I> output() {
|
||||
static_assert(I < output_count, "output index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<fixed_string Name>
|
||||
auto input() {
|
||||
constexpr std::size_t idx = index_of<Name, InNames...>();
|
||||
static_assert(idx != npos, "unknown input port name");
|
||||
return input<idx>();
|
||||
}
|
||||
|
||||
template<fixed_string Name>
|
||||
auto output() {
|
||||
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
||||
static_assert(idx != npos, "unknown output port name");
|
||||
return output<idx>();
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
|
||||
return *std::get<I>(input_channels_);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_input_channel(
|
||||
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
|
||||
std::get<I>(input_channels_) = std::move(ch);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
||||
std::get<I>(output_channels_) = ch;
|
||||
}
|
||||
|
||||
private:
|
||||
template<std::size_t... Is>
|
||||
void init_input_channels(std::index_sequence<Is...>) {
|
||||
((std::get<Is>(input_channels_) =
|
||||
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
|
||||
...);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void enable_inputs(std::index_sequence<Is...>) {
|
||||
(std::get<Is>(input_channels_)->enable(), ...);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void disable_inputs(std::index_sequence<Is...>) {
|
||||
(std::get<Is>(input_channels_)->disable(), ...);
|
||||
}
|
||||
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_input_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
|
||||
|
||||
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
|
||||
std::make_index_sequence<input_count>{}));
|
||||
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
||||
|
||||
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||
std::make_index_sequence<output_count>{}));
|
||||
|
||||
void run_loop() {
|
||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
auto t0 = clock_t::now();
|
||||
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
if constexpr (std::is_void_v<return_raw>) {
|
||||
std::apply([this](auto&&... a) { obj_(std::forward<decltype(a)>(a)...); }, args);
|
||||
} else {
|
||||
auto result = std::apply([this](auto&&... a) { return obj_(std::forward<decltype(a)>(a)...); }, args);
|
||||
push_outputs(normalise(std::move(result)),
|
||||
std::make_index_sequence<output_count>{});
|
||||
}
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
} catch (const ChannelOverflowError& e) {
|
||||
std::cerr << "[kpn] overflow: " << e.what() << "\n";
|
||||
} catch (...) {
|
||||
if (error_handler_ && error_handler_(name_, std::current_exception()))
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
args_tuple pop_inputs(std::index_sequence<Is...>) {
|
||||
return {std::get<Is>(input_channels_)->pop()...};
|
||||
}
|
||||
|
||||
template<typename R = return_raw>
|
||||
static return_tuple normalise(R&& r) {
|
||||
if constexpr (is_tuple_v<R>) return std::move(r);
|
||||
else return std::make_tuple(std::move(r));
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||
(push_one<Is>(std::get<Is>(std::move(result))), ...);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
|
||||
auto* ch = std::get<I>(output_channels_);
|
||||
if (!ch) return;
|
||||
try {
|
||||
ch->push(std::move(val));
|
||||
} catch (const ChannelOverflowError&) {
|
||||
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
|
||||
}
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
static std::string output_port_label() {
|
||||
if constexpr (sizeof...(OutNames) > 0) {
|
||||
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
|
||||
return std::string("output['") + std::string(names[I]) + "']";
|
||||
} else {
|
||||
return "output[" + std::to_string(I) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
Obj& obj_;
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
input_channels_t input_channels_;
|
||||
output_channels_t output_channels_{};
|
||||
std::atomic<bool> stop_flag_{false};
|
||||
std::jthread thread_;
|
||||
NodeStats stats_;
|
||||
NodeErrorHandler error_handler_;
|
||||
void start() override { pool->start(); Base::start(); }
|
||||
void stop() override { Base::stop(); pool->stop(); }
|
||||
};
|
||||
|
||||
// ── make_node overloads for callable objects ──────────────────────────────────
|
||||
@@ -569,35 +100,24 @@ auto make_node(Obj& obj, in<InNames...>, out<OutNames...>, std::size_t fifo_capa
|
||||
}
|
||||
|
||||
// ── make_node factory (NTTP) ──────────────────────────────────────────────────
|
||||
//
|
||||
// Usage:
|
||||
// make_node<func>(capacity)
|
||||
// make_node<func, "label">(capacity)
|
||||
// make_node<func, "label", 1>(capacity) // UniqueTag=1
|
||||
// make_node<func>(in<"a","b">{}, capacity)
|
||||
// make_node<func, "label">(in<"a","b">{}, out<"x">{}, capacity)
|
||||
|
||||
// No port names
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
|
||||
auto make_node(std::size_t fifo_capacity = 5) {
|
||||
return Node<Func, in<>, out<>, Label, UniqueTag>(fifo_capacity);
|
||||
}
|
||||
|
||||
// in<> only
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||
fixed_string... InNames>
|
||||
auto make_node(in<InNames...>, std::size_t fifo_capacity = 5) {
|
||||
return Node<Func, in<InNames...>, out<>, Label, UniqueTag>(fifo_capacity);
|
||||
}
|
||||
|
||||
// out<> only
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||
fixed_string... OutNames>
|
||||
auto make_node(out<OutNames...>, std::size_t fifo_capacity = 5) {
|
||||
return Node<Func, in<>, out<OutNames...>, Label, UniqueTag>(fifo_capacity);
|
||||
}
|
||||
|
||||
// in<> and out<>
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||
fixed_string... InNames, fixed_string... OutNames>
|
||||
auto make_node(in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
#pragma once
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "fixed_string.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
#include "scheduler.hpp"
|
||||
#include "traits.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── PoolNode ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reactive alternative to Node<>. Instead of owning a blocked thread, the node
|
||||
// is submitted to a shared IScheduler whenever all its input channels become
|
||||
// non-empty. A single fire_once() call pops all inputs, executes the function,
|
||||
// and pushes outputs. At most one fire_once() runs at a time (queued_ flag).
|
||||
//
|
||||
// Source nodes (input_count == 0) submit themselves immediately on start() and
|
||||
// resubmit after each fire_once().
|
||||
//
|
||||
// Multiple PoolNodes can share one ThreadPool for resource-bounded execution,
|
||||
// or each can have a dedicated single-thread pool for serialisation.
|
||||
|
||||
template<auto Func,
|
||||
typename InputTag = in<>,
|
||||
typename OutputTag = out<>,
|
||||
fixed_string Label = "",
|
||||
std::size_t UniqueTag = 0>
|
||||
class PoolNode;
|
||||
|
||||
template<auto Func, fixed_string... InNames, fixed_string... OutNames,
|
||||
fixed_string Label, std::size_t UniqueTag>
|
||||
class PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
|
||||
public:
|
||||
using F = decltype(Func);
|
||||
using args_tuple = args_t<F>;
|
||||
using return_raw = return_t<F>;
|
||||
using return_tuple = normalised_return_t<return_raw>;
|
||||
|
||||
static constexpr std::string_view label() { return Label.view(); }
|
||||
static constexpr std::size_t unique_tag = UniqueTag;
|
||||
|
||||
static constexpr std::size_t input_count = arity_v<F>;
|
||||
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
||||
|
||||
static_assert(
|
||||
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
||||
"make_pool_node: number of input names must match function arity, or provide none"
|
||||
);
|
||||
static_assert(
|
||||
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
||||
"make_pool_node: number of output names must match return tuple size, or provide none"
|
||||
);
|
||||
|
||||
explicit PoolNode(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5)
|
||||
: scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
init_input_channels(std::make_index_sequence<input_count>{});
|
||||
}
|
||||
|
||||
~PoolNode() override { stop(); }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void start() override {
|
||||
enable_inputs(std::make_index_sequence<input_count>{});
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_relaxed);
|
||||
register_callbacks(std::make_index_sequence<input_count>{});
|
||||
if constexpr (input_count == 0)
|
||||
try_submit(0.5f);
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_seq_cst);
|
||||
disable_inputs(std::make_index_sequence<input_count>{});
|
||||
// fire_once() observes stop_flag_ and will not resubmit.
|
||||
// We do not wait for an in-flight fire_once() to complete here;
|
||||
// callers that need that guarantee should call scheduler_->drain() first.
|
||||
}
|
||||
|
||||
bool running() const override {
|
||||
return !stop_flag_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double qwait_ms = stats_.queue_wait_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms + blocked_ms;
|
||||
return {
|
||||
name, frames, exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
blocked_ms,
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||
qwait_ms,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Port access — by index ────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I>
|
||||
InputPort<PoolNode, I> input() {
|
||||
static_assert(I < input_count, "input index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
OutputPort<PoolNode, I> output() {
|
||||
static_assert(I < output_count, "output index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
// ── Port access — by name ─────────────────────────────────────────────────
|
||||
|
||||
template<fixed_string Name>
|
||||
auto input() {
|
||||
constexpr std::size_t idx = index_of<Name, InNames...>();
|
||||
static_assert(idx != npos, "unknown input port name");
|
||||
return input<idx>();
|
||||
}
|
||||
|
||||
template<fixed_string Name>
|
||||
auto output() {
|
||||
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
||||
static_assert(idx != npos, "unknown output port name");
|
||||
return output<idx>();
|
||||
}
|
||||
|
||||
// ── Internal channel accessors ────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I>
|
||||
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
|
||||
return *std::get<I>(input_channels_);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_input_channel(
|
||||
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
|
||||
std::get<I>(input_channels_) = std::move(ch);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_channel(
|
||||
Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
||||
std::get<I>(output_channels_) = ch;
|
||||
}
|
||||
|
||||
private:
|
||||
// ── Channel storage ───────────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t... Is>
|
||||
void init_input_channels(std::index_sequence<Is...>) {
|
||||
((std::get<Is>(input_channels_) =
|
||||
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
|
||||
...);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void enable_inputs(std::index_sequence<Is...>) {
|
||||
(std::get<Is>(input_channels_)->enable(), ...);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void disable_inputs(std::index_sequence<Is...>) {
|
||||
(std::get<Is>(input_channels_)->disable(), ...);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void register_callbacks(std::index_sequence<Is...>) {
|
||||
(std::get<Is>(input_channels_)->set_push_callback(
|
||||
[this] { on_input_ready(); }), ...);
|
||||
}
|
||||
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_input_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
|
||||
|
||||
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
|
||||
std::make_index_sequence<input_count>{}));
|
||||
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
||||
|
||||
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||
std::make_index_sequence<output_count>{}));
|
||||
|
||||
// ── Scheduling ────────────────────────────────────────────────────────────
|
||||
|
||||
// Called by channel push_callbacks (on the producer's thread).
|
||||
void on_input_ready() {
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||
std::size_t ready = count_ready(std::make_index_sequence<input_count>{});
|
||||
if (ready == input_count)
|
||||
try_submit(compute_priority());
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
std::size_t count_ready(std::index_sequence<Is...>) {
|
||||
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
|
||||
}
|
||||
|
||||
float compute_priority() {
|
||||
if constexpr (input_count == 0) return 0.5f;
|
||||
float sum = 0.0f;
|
||||
sum_fill(sum, std::make_index_sequence<input_count>{});
|
||||
return sum / static_cast<float>(input_count);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void sum_fill(float& sum, std::index_sequence<Is...>) {
|
||||
((sum += std::get<Is>(input_channels_)->capacity() > 0
|
||||
? float(std::get<Is>(input_channels_)->approx_size())
|
||||
/ float(std::get<Is>(input_channels_)->capacity())
|
||||
: 0.5f), ...);
|
||||
}
|
||||
|
||||
void try_submit(float priority) {
|
||||
bool expected = false;
|
||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||
scheduler_->submit([this] { fire_once(); }, priority);
|
||||
}
|
||||
|
||||
// ── Execution ─────────────────────────────────────────────────────────────
|
||||
|
||||
void fire_once() {
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||
queued_.store(false, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
|
||||
// Record queue wait time (submission → now) and mark as executing
|
||||
auto t0 = clock_t::now();
|
||||
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
t0.time_since_epoch()).count();
|
||||
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
||||
|
||||
try {
|
||||
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
||||
auto t1 = clock_t::now();
|
||||
stats_.record_queue_wait(duration_t(t1 - t0));
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
if constexpr (std::is_void_v<return_raw>) {
|
||||
std::apply(Func, args);
|
||||
} else {
|
||||
auto result = std::apply(Func, args);
|
||||
push_outputs(normalise(std::move(result)),
|
||||
std::make_index_sequence<output_count>{});
|
||||
}
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
// blocked_time = 0 for pool nodes (we don't block waiting for inputs)
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||
} catch (const ChannelClosedError&) {
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_release);
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
return;
|
||||
} catch (const ChannelOverflowError& e) {
|
||||
std::cerr << "[kpn] pool overflow: " << e.what() << "\n";
|
||||
} catch (...) {
|
||||
if (error_handler_ && error_handler_(name_, std::current_exception())) {
|
||||
// continue — fall through to resubmit check
|
||||
} else {
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_release);
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_release);
|
||||
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||
|
||||
// Source nodes always resubmit; others resubmit only if inputs are ready.
|
||||
if constexpr (input_count == 0) {
|
||||
try_submit(0.5f);
|
||||
} else {
|
||||
on_input_ready();
|
||||
}
|
||||
}
|
||||
|
||||
// Pop all inputs — safe because we're the sole consumer and fire_once
|
||||
// is guarded by queued_ (only one fire_once runs at a time).
|
||||
template<std::size_t... Is>
|
||||
args_tuple pop_inputs(std::index_sequence<Is...>) {
|
||||
return {pop_one<Is>()...};
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
std::tuple_element_t<I, args_tuple> pop_one() {
|
||||
auto& ch = *std::get<I>(input_channels_);
|
||||
std::tuple_element_t<I, args_tuple> val;
|
||||
if (!ch.try_pop_now(val))
|
||||
throw ChannelClosedError{};
|
||||
return val;
|
||||
}
|
||||
|
||||
template<typename R = return_raw>
|
||||
static return_tuple normalise(R&& r) {
|
||||
if constexpr (is_tuple_v<R>) return std::move(r);
|
||||
else return std::make_tuple(std::move(r));
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||
(push_one_out<Is>(std::get<Is>(std::move(result))), ...);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
|
||||
auto* ch = std::get<I>(output_channels_);
|
||||
if (!ch) return;
|
||||
try {
|
||||
ch->push(std::move(val));
|
||||
} catch (const ChannelOverflowError&) {
|
||||
throw ChannelOverflowError(ch->capacity(),
|
||||
"pool node '" + name_ + "' " + output_port_label<I>());
|
||||
}
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
static std::string output_port_label() {
|
||||
if constexpr (sizeof...(OutNames) > 0) {
|
||||
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
|
||||
return std::string("output['") + std::string(names[I]) + "']";
|
||||
} else {
|
||||
return "output[" + std::to_string(I) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
std::shared_ptr<IScheduler> scheduler_;
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
input_channels_t input_channels_;
|
||||
output_channels_t output_channels_{};
|
||||
std::atomic<bool> stop_flag_{true};
|
||||
std::atomic<bool> queued_{false};
|
||||
NodeStats stats_;
|
||||
NodeErrorHandler error_handler_;
|
||||
std::chrono::milliseconds max_exec_time_{0};
|
||||
};
|
||||
|
||||
// ── PoolObjectNode ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Same as PoolNode but wraps a stateful callable object (functor / class with
|
||||
// operator()). The object must outlive the PoolObjectNode.
|
||||
|
||||
template<typename Obj,
|
||||
typename InputTag = in<>,
|
||||
typename OutputTag = out<>,
|
||||
fixed_string Label = "",
|
||||
std::size_t UniqueTag = 0>
|
||||
class PoolObjectNode;
|
||||
|
||||
template<typename Obj, fixed_string... InNames, fixed_string... OutNames,
|
||||
fixed_string Label, std::size_t UniqueTag>
|
||||
class PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
|
||||
public:
|
||||
using F = decltype(&Obj::operator());
|
||||
using args_tuple = args_t<F>;
|
||||
using return_raw = return_t<F>;
|
||||
using return_tuple = normalised_return_t<return_raw>;
|
||||
|
||||
static constexpr std::string_view label() { return Label.view(); }
|
||||
static constexpr std::size_t unique_tag = UniqueTag;
|
||||
|
||||
static constexpr std::size_t input_count = arity_v<F>;
|
||||
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
||||
|
||||
static_assert(
|
||||
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
||||
"make_pool_node: number of input names must match operator() arity, or provide none"
|
||||
);
|
||||
static_assert(
|
||||
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
||||
"make_pool_node: number of output names must match return tuple size, or provide none"
|
||||
);
|
||||
|
||||
explicit PoolObjectNode(Obj& obj, std::shared_ptr<IScheduler> sched,
|
||||
std::size_t fifo_capacity = 5)
|
||||
: obj_(obj), scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
init_input_channels(std::make_index_sequence<input_count>{});
|
||||
}
|
||||
|
||||
~PoolObjectNode() override { stop(); }
|
||||
|
||||
void start() override {
|
||||
enable_inputs(std::make_index_sequence<input_count>{});
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_relaxed);
|
||||
register_callbacks(std::make_index_sequence<input_count>{});
|
||||
if constexpr (input_count == 0)
|
||||
try_submit(0.5f);
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_seq_cst);
|
||||
disable_inputs(std::make_index_sequence<input_count>{});
|
||||
}
|
||||
|
||||
bool running() const override { return !stop_flag_.load(std::memory_order_relaxed); }
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double qwait_ms = stats_.queue_wait_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms + blocked_ms;
|
||||
return {
|
||||
name, frames, exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
blocked_ms,
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||
qwait_ms,
|
||||
};
|
||||
}
|
||||
|
||||
template<std::size_t I> InputPort<PoolObjectNode, I> input() { return {*this}; }
|
||||
template<std::size_t I> OutputPort<PoolObjectNode, I> output() { return {*this}; }
|
||||
|
||||
template<fixed_string Name>
|
||||
auto input() {
|
||||
constexpr std::size_t idx = index_of<Name, InNames...>();
|
||||
static_assert(idx != npos, "unknown input port name");
|
||||
return input<idx>();
|
||||
}
|
||||
template<fixed_string Name>
|
||||
auto output() {
|
||||
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
||||
static_assert(idx != npos, "unknown output port name");
|
||||
return output<idx>();
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
|
||||
return *std::get<I>(input_channels_);
|
||||
}
|
||||
template<std::size_t I>
|
||||
void set_input_channel(std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
|
||||
std::get<I>(input_channels_) = std::move(ch);
|
||||
}
|
||||
template<std::size_t I>
|
||||
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
||||
std::get<I>(output_channels_) = ch;
|
||||
}
|
||||
|
||||
private:
|
||||
template<std::size_t... Is>
|
||||
void init_input_channels(std::index_sequence<Is...>) {
|
||||
((std::get<Is>(input_channels_) =
|
||||
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
|
||||
...);
|
||||
}
|
||||
template<std::size_t... Is> void enable_inputs(std::index_sequence<Is...>) { (std::get<Is>(input_channels_)->enable(), ...); }
|
||||
template<std::size_t... Is> void disable_inputs(std::index_sequence<Is...>) { (std::get<Is>(input_channels_)->disable(), ...); }
|
||||
template<std::size_t... Is>
|
||||
void register_callbacks(std::index_sequence<Is...>) {
|
||||
(std::get<Is>(input_channels_)->set_push_callback([this] { on_input_ready(); }), ...);
|
||||
}
|
||||
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_input_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
|
||||
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
|
||||
std::make_index_sequence<input_count>{}));
|
||||
|
||||
template<typename Tup, std::size_t... Is>
|
||||
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
||||
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
||||
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||
std::make_index_sequence<output_count>{}));
|
||||
|
||||
void on_input_ready() {
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||
std::size_t ready = count_ready(std::make_index_sequence<input_count>{});
|
||||
if (ready == input_count) try_submit(compute_priority());
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
std::size_t count_ready(std::index_sequence<Is...>) {
|
||||
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
|
||||
}
|
||||
|
||||
float compute_priority() {
|
||||
if constexpr (input_count == 0) return 0.5f;
|
||||
float sum = 0.0f;
|
||||
sum_fill(sum, std::make_index_sequence<input_count>{});
|
||||
return sum / static_cast<float>(input_count);
|
||||
}
|
||||
template<std::size_t... Is>
|
||||
void sum_fill(float& sum, std::index_sequence<Is...>) {
|
||||
((sum += std::get<Is>(input_channels_)->capacity() > 0
|
||||
? float(std::get<Is>(input_channels_)->approx_size())
|
||||
/ float(std::get<Is>(input_channels_)->capacity())
|
||||
: 0.5f), ...);
|
||||
}
|
||||
|
||||
void try_submit(float priority) {
|
||||
bool expected = false;
|
||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||
scheduler_->submit([this] { fire_once(); }, priority);
|
||||
}
|
||||
|
||||
void fire_once() {
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||
queued_.store(false, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
auto t0 = clock_t::now();
|
||||
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
t0.time_since_epoch()).count();
|
||||
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
||||
|
||||
try {
|
||||
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
||||
auto t1 = clock_t::now();
|
||||
stats_.record_queue_wait(duration_t(t1 - t0));
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
if constexpr (std::is_void_v<return_raw>) {
|
||||
std::apply([this](auto&&... a) { obj_(std::forward<decltype(a)>(a)...); }, args);
|
||||
} else {
|
||||
auto result = std::apply([this](auto&&... a) { return obj_(std::forward<decltype(a)>(a)...); }, args);
|
||||
push_outputs(normalise(std::move(result)), std::make_index_sequence<output_count>{});
|
||||
}
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||
} catch (const ChannelClosedError&) {
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_release);
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
return;
|
||||
} catch (const ChannelOverflowError& e) {
|
||||
std::cerr << "[kpn] pool overflow: " << e.what() << "\n";
|
||||
} catch (...) {
|
||||
if (error_handler_ && error_handler_(name_, std::current_exception())) {
|
||||
} else {
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_release);
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_release);
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||
if constexpr (input_count == 0) try_submit(0.5f);
|
||||
else on_input_ready();
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
args_tuple pop_inputs(std::index_sequence<Is...>) { return {pop_one<Is>()...}; }
|
||||
|
||||
template<std::size_t I>
|
||||
std::tuple_element_t<I, args_tuple> pop_one() {
|
||||
auto& ch = *std::get<I>(input_channels_);
|
||||
std::tuple_element_t<I, args_tuple> val;
|
||||
if (!ch.try_pop_now(val)) throw ChannelClosedError{};
|
||||
return val;
|
||||
}
|
||||
|
||||
template<typename R = return_raw>
|
||||
static return_tuple normalise(R&& r) {
|
||||
if constexpr (is_tuple_v<R>) return std::move(r);
|
||||
else return std::make_tuple(std::move(r));
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||
(push_one_out<Is>(std::get<Is>(std::move(result))), ...);
|
||||
}
|
||||
template<std::size_t I>
|
||||
void push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
|
||||
auto* ch = std::get<I>(output_channels_);
|
||||
if (!ch) return;
|
||||
try {
|
||||
ch->push(std::move(val));
|
||||
} catch (const ChannelOverflowError&) {
|
||||
throw ChannelOverflowError(ch->capacity(),
|
||||
"pool node '" + name_ + "'");
|
||||
}
|
||||
}
|
||||
|
||||
Obj& obj_;
|
||||
std::shared_ptr<IScheduler> scheduler_;
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
input_channels_t input_channels_;
|
||||
output_channels_t output_channels_{};
|
||||
std::atomic<bool> stop_flag_{true};
|
||||
std::atomic<bool> queued_{false};
|
||||
NodeStats stats_;
|
||||
NodeErrorHandler error_handler_;
|
||||
std::chrono::milliseconds max_exec_time_{0};
|
||||
};
|
||||
|
||||
// ── make_pool_node factory (NTTP) ─────────────────────────────────────────────
|
||||
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
|
||||
auto make_pool_node(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5) {
|
||||
return PoolNode<Func, in<>, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||
fixed_string... InNames>
|
||||
auto make_pool_node(std::shared_ptr<IScheduler> sched, in<InNames...>,
|
||||
std::size_t fifo_capacity = 5) {
|
||||
return PoolNode<Func, in<InNames...>, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||
fixed_string... OutNames>
|
||||
auto make_pool_node(std::shared_ptr<IScheduler> sched, out<OutNames...>,
|
||||
std::size_t fifo_capacity = 5) {
|
||||
return PoolNode<Func, in<>, out<OutNames...>, Label, UniqueTag>(std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||
fixed_string... InNames, fixed_string... OutNames>
|
||||
auto make_pool_node(std::shared_ptr<IScheduler> sched, in<InNames...>, out<OutNames...>,
|
||||
std::size_t fifo_capacity = 5) {
|
||||
return PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>(
|
||||
std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
// ── make_pool_node factory (callable object) ──────────────────────────────────
|
||||
|
||||
template<typename Obj>
|
||||
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched,
|
||||
std::size_t fifo_capacity = 5) {
|
||||
return PoolObjectNode<Obj, in<>, out<>>(obj, std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
template<typename Obj, fixed_string... InNames>
|
||||
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched, in<InNames...>,
|
||||
std::size_t fifo_capacity = 5) {
|
||||
return PoolObjectNode<Obj, in<InNames...>, out<>>(obj, std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
template<typename Obj, fixed_string... OutNames>
|
||||
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched, out<OutNames...>,
|
||||
std::size_t fifo_capacity = 5) {
|
||||
return PoolObjectNode<Obj, in<>, out<OutNames...>>(obj, std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
template<typename Obj, fixed_string... InNames, fixed_string... OutNames>
|
||||
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched,
|
||||
in<InNames...>, out<OutNames...>,
|
||||
std::size_t fifo_capacity = 5) {
|
||||
return PoolObjectNode<Obj, in<InNames...>, out<OutNames...>>(
|
||||
obj, std::move(sched), fifo_capacity);
|
||||
}
|
||||
|
||||
} // namespace kpn
|
||||
@@ -0,0 +1,311 @@
|
||||
#pragma once
|
||||
// Auto-binding helpers for KPN++ Python bindings.
|
||||
//
|
||||
// Usage in your binding .cpp:
|
||||
//
|
||||
// #define KPN_BUILD_PYTHON
|
||||
// #include <kpn/python/auto_bind.hpp>
|
||||
//
|
||||
// int produce() { return 42; }
|
||||
// int double_it(int x) { return x * 2; }
|
||||
// void print_it(int x) { std::cout << x << '\n'; }
|
||||
//
|
||||
// using MyNodes = kpn::python::NodeRegistry<
|
||||
// kpn::python::Entry<produce, "produce">,
|
||||
// kpn::python::Entry<double_it, "double_it">,
|
||||
// kpn::python::Entry<print_it, "print_it">
|
||||
// >;
|
||||
//
|
||||
// NB_MODULE(my_kpn, m) {
|
||||
// kpn::python::bind_network<MyNodes>(m); // KPN_BIND_PYTHON behaviour
|
||||
// kpn::python::bind_debug<MyNodes>(m); // KPN_PYTHON_DEBUG behaviour
|
||||
// }
|
||||
//
|
||||
// bind_network registers:
|
||||
// - Network class (PyNetwork<auto-deduced-variant>) with auto-registered converters
|
||||
// - make_<name>(capacity=5) factory for each entry
|
||||
// - <Name>Node class for each entry
|
||||
//
|
||||
// bind_debug additionally registers each raw C++ function as a free Python
|
||||
// callable (e.g. double_it(5) → 10) so node logic can be tested without a network.
|
||||
//
|
||||
// To support a custom type T, specialise kpn::PythonConverter<T> before calling
|
||||
// bind_network:
|
||||
//
|
||||
// namespace kpn {
|
||||
// template<> struct PythonConverter<MyVec3> {
|
||||
// static constexpr const char* type_name = "vec3"; // optional friendly name
|
||||
// static nb::object to_python(const MyVec3& v) { ... }
|
||||
// static MyVec3 from_python(nb::object o) { ... }
|
||||
// };
|
||||
// } // namespace kpn
|
||||
|
||||
#include "../variant_node.hpp"
|
||||
#include "../traits.hpp"
|
||||
#include "bindings.hpp"
|
||||
|
||||
#ifdef KPN_BUILD_PYTHON
|
||||
#include <nanobind/nanobind.h>
|
||||
#include <nanobind/stl/shared_ptr.h>
|
||||
#include <nanobind/stl/string.h>
|
||||
#include <nanobind/stl/vector.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
|
||||
// ── PythonConverter specialisations for built-in nanobind-castable types ─────
|
||||
// These live in kpn:: to match the primary template in variant_node.hpp.
|
||||
|
||||
namespace kpn {
|
||||
|
||||
template<> struct PythonConverter<int> {
|
||||
static constexpr const char* type_name = "int";
|
||||
static nanobind::object to_python(const int& v) { return nanobind::cast(v); }
|
||||
static int from_python(nanobind::object o) { return nanobind::cast<int>(std::move(o)); }
|
||||
};
|
||||
|
||||
template<> struct PythonConverter<float> {
|
||||
static constexpr const char* type_name = "float";
|
||||
static nanobind::object to_python(const float& v) { return nanobind::cast(v); }
|
||||
static float from_python(nanobind::object o) { return nanobind::cast<float>(std::move(o)); }
|
||||
};
|
||||
|
||||
template<> struct PythonConverter<double> {
|
||||
static constexpr const char* type_name = "double";
|
||||
static nanobind::object to_python(const double& v) { return nanobind::cast(v); }
|
||||
static double from_python(nanobind::object o) { return nanobind::cast<double>(std::move(o)); }
|
||||
};
|
||||
|
||||
template<> struct PythonConverter<bool> {
|
||||
static constexpr const char* type_name = "bool";
|
||||
static nanobind::object to_python(const bool& v) { return nanobind::cast(v); }
|
||||
static bool from_python(nanobind::object o) { return nanobind::cast<bool>(std::move(o)); }
|
||||
};
|
||||
|
||||
template<> struct PythonConverter<std::string> {
|
||||
static constexpr const char* type_name = "str";
|
||||
static nanobind::object to_python(const std::string& v) { return nanobind::cast(v); }
|
||||
static std::string from_python(nanobind::object o) {
|
||||
return nanobind::cast<std::string>(std::move(o));
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
namespace kpn::python {
|
||||
namespace nb = nanobind;
|
||||
|
||||
// ── Entry<Func, Name> ─────────────────────────────────────────────────────────
|
||||
// Compile-time descriptor for one bindable node function.
|
||||
|
||||
template<auto Func, fixed_string Name>
|
||||
struct Entry {
|
||||
static constexpr auto func = Func;
|
||||
static constexpr auto name = Name;
|
||||
};
|
||||
|
||||
// ── NodeRegistry<Es...> ───────────────────────────────────────────────────────
|
||||
|
||||
template<typename... Es>
|
||||
struct NodeRegistry {
|
||||
using entries_tuple = std::tuple<Es...>;
|
||||
static constexpr std::size_t size = sizeof...(Es);
|
||||
};
|
||||
|
||||
// ── Internal TMP ──────────────────────────────────────────────────────────────
|
||||
|
||||
namespace detail {
|
||||
|
||||
// All non-void port types for a single function (args + normalised returns).
|
||||
template<auto Func>
|
||||
struct entry_port_types {
|
||||
using args = args_t<decltype(Func)>;
|
||||
using ret = normalised_return_t<return_t<decltype(Func)>>;
|
||||
using type = decltype(std::tuple_cat(std::declval<args>(), std::declval<ret>()));
|
||||
};
|
||||
|
||||
// Flat tuple of all types across all entries (may contain duplicates).
|
||||
template<typename... Es>
|
||||
struct all_types_flat {
|
||||
using type = decltype(std::tuple_cat(
|
||||
std::declval<typename entry_port_types<Es::func>::type>()...));
|
||||
};
|
||||
|
||||
template<typename Registry>
|
||||
struct registry_flat_types;
|
||||
|
||||
template<typename... Es>
|
||||
struct registry_flat_types<NodeRegistry<Es...>> {
|
||||
using type = typename all_types_flat<Es...>::type;
|
||||
};
|
||||
|
||||
// Unpack a tuple into unique_types_t (which takes a pack, not a tuple).
|
||||
// unique_types_t<T> takes Ts... not std::tuple<Ts...>, so we need this bridge.
|
||||
template<typename Tuple>
|
||||
struct unpack_unique;
|
||||
|
||||
template<typename... Ts>
|
||||
struct unpack_unique<std::tuple<Ts...>> {
|
||||
using type = kpn::detail::unique_types_t<Ts...>;
|
||||
};
|
||||
|
||||
// SFINAE: does PythonConverter<T> have a 'type_name' member?
|
||||
template<typename Conv, typename = void>
|
||||
struct has_type_name : std::false_type {};
|
||||
|
||||
template<typename Conv>
|
||||
struct has_type_name<Conv, std::void_t<decltype(Conv::type_name)>> : std::true_type {};
|
||||
|
||||
inline std::string make_class_name(std::string_view snake) {
|
||||
std::string result(snake);
|
||||
if (!result.empty()) result[0] = static_cast<char>(std::toupper(result[0]));
|
||||
result += "Node";
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ── registry_variant_t<Registry> ─────────────────────────────────────────────
|
||||
// Deduces std::variant<UniqueTypes...> from all port types across the registry.
|
||||
|
||||
template<typename Registry>
|
||||
using registry_variant_t = typename kpn::detail::tuple_to_variant<
|
||||
typename detail::unpack_unique<
|
||||
typename detail::registry_flat_types<Registry>::type>::type
|
||||
>::type;
|
||||
|
||||
// ── Converter registration ────────────────────────────────────────────────────
|
||||
|
||||
template<typename T, typename Variant>
|
||||
void register_one_type(PyNetwork<Variant>& net) {
|
||||
const char* friendly = nullptr;
|
||||
if constexpr (detail::has_type_name<PythonConverter<T>>::value)
|
||||
friendly = PythonConverter<T>::type_name;
|
||||
|
||||
net.template register_full_type<T>(
|
||||
[](const T& v) -> nb::object { return PythonConverter<T>::to_python(v); },
|
||||
[](nb::object o) -> T { return PythonConverter<T>::from_python(std::move(o)); },
|
||||
friendly);
|
||||
}
|
||||
|
||||
template<typename Variant, typename... Ts>
|
||||
void register_types_impl(PyNetwork<Variant>& net, std::tuple<Ts...>*) {
|
||||
(register_one_type<Ts>(net), ...);
|
||||
}
|
||||
|
||||
template<typename Registry, typename Variant>
|
||||
void register_all_converters(PyNetwork<Variant>& net) {
|
||||
using Flat = typename detail::registry_flat_types<Registry>::type;
|
||||
using Unique = typename detail::unpack_unique<Flat>::type;
|
||||
register_types_impl(net, static_cast<Unique*>(nullptr));
|
||||
}
|
||||
|
||||
// ── Per-entry class + factory registration ───────────────────────────────────
|
||||
|
||||
namespace detail {
|
||||
|
||||
template<typename E, typename Variant>
|
||||
void register_one_entry(nb::module_& m) {
|
||||
using Wrapper = VariantNodeWrapper<E::func, Variant>;
|
||||
|
||||
auto class_name = make_class_name(E::name.view());
|
||||
auto make_name = "make_" + std::string(E::name.view());
|
||||
|
||||
nb::class_<Wrapper, IVariantNode<Variant>>(m, class_name.c_str())
|
||||
.def("__init__", [](Wrapper* self, std::size_t cap) {
|
||||
new (self) Wrapper(cap);
|
||||
}, nb::arg("capacity") = 5);
|
||||
|
||||
m.def(make_name.c_str(),
|
||||
[](std::size_t cap) -> std::shared_ptr<IVariantNode<Variant>> {
|
||||
return std::make_shared<Wrapper>(cap);
|
||||
},
|
||||
nb::arg("capacity") = 5);
|
||||
}
|
||||
|
||||
template<typename Variant, typename... Es>
|
||||
void register_entries_impl(nb::module_& m, std::tuple<Es...>*) {
|
||||
(register_one_entry<Es, Variant>(m), ...);
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ── bind_network<Registry> ────────────────────────────────────────────────────
|
||||
// Registers:
|
||||
// - INode — base class (opaque Python handle)
|
||||
// - Network — PyNetwork with auto-registered converters
|
||||
// - <Name>Node — VariantNodeWrapper for each entry
|
||||
// - make_<name>() — factory returning shared_ptr<INode>
|
||||
|
||||
template<typename Registry>
|
||||
void bind_network(nb::module_& m) {
|
||||
using Variant = registry_variant_t<Registry>;
|
||||
using Net = PyNetwork<Variant>;
|
||||
using Entries = typename Registry::entries_tuple;
|
||||
|
||||
nb::class_<IVariantNode<Variant>>(m, "INode");
|
||||
|
||||
nb::class_<Net>(m, "Network")
|
||||
.def("__init__", [](Net* self) {
|
||||
new (self) Net();
|
||||
register_all_converters<Registry>(*self);
|
||||
})
|
||||
// add(name, c++_node)
|
||||
.def("add", [](Net& self, std::string name,
|
||||
std::shared_ptr<IVariantNode<Variant>> node) {
|
||||
self.add(std::move(name), std::move(node));
|
||||
}, nb::arg("name"), nb::arg("node"))
|
||||
// add_node(name, callable, inputs=[...], outputs=[...], capacity=5)
|
||||
.def("add_node", &Net::add_node_python,
|
||||
nb::arg("name"),
|
||||
nb::arg("callable"),
|
||||
nb::arg("inputs") = std::vector<std::string>{},
|
||||
nb::arg("outputs") = std::vector<std::string>{},
|
||||
nb::arg("capacity") = std::size_t(5))
|
||||
.def("connect", &Net::connect,
|
||||
nb::arg("src"), nb::arg("out_idx"),
|
||||
nb::arg("dst"), nb::arg("in_idx"))
|
||||
.def("build", &Net::build)
|
||||
.def("start", &Net::start)
|
||||
.def("stop", &Net::stop)
|
||||
.def("read", &Net::read,
|
||||
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"))
|
||||
;
|
||||
|
||||
detail::register_entries_impl<Variant>(m, static_cast<Entries*>(nullptr));
|
||||
}
|
||||
|
||||
// ── bind_debug<Registry> ─────────────────────────────────────────────────────
|
||||
// Exposes each node's raw C++ function as a free Python callable so node logic
|
||||
// can be unit-tested without constructing a network.
|
||||
//
|
||||
// Example: assert kpn.double_it(5) == 10
|
||||
|
||||
namespace detail {
|
||||
|
||||
template<typename E>
|
||||
void bind_one_debug(nb::module_& m) {
|
||||
auto name_str = std::string(E::name.view());
|
||||
m.def(name_str.c_str(), E::func);
|
||||
}
|
||||
|
||||
template<typename... Es>
|
||||
void bind_debug_impl(nb::module_& m, std::tuple<Es...>*) {
|
||||
(bind_one_debug<Es>(m), ...);
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
template<typename Registry>
|
||||
void bind_debug(nb::module_& m) {
|
||||
using Entries = typename Registry::entries_tuple;
|
||||
detail::bind_debug_impl(m, static_cast<Entries*>(nullptr));
|
||||
}
|
||||
|
||||
} // namespace kpn::python
|
||||
|
||||
#endif // KPN_BUILD_PYTHON
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#pragma once
|
||||
#include "diagnostics.hpp"
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <queue>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── IScheduler ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct IScheduler {
|
||||
virtual ~IScheduler() = default;
|
||||
|
||||
// Submit a task with an optional priority in [0, 1]. Higher = run sooner.
|
||||
virtual void submit(std::function<void()> task, float priority = 0.5f) = 0;
|
||||
|
||||
// Start worker threads. Must be called before submit().
|
||||
virtual void start() = 0;
|
||||
|
||||
// Halt: signal workers to exit and join them. Pending tasks are discarded.
|
||||
virtual void stop() = 0;
|
||||
|
||||
// Drain: block until all in-flight tasks complete. Workers keep running.
|
||||
virtual void drain() = 0;
|
||||
};
|
||||
|
||||
// ── ThreadPool ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Work-stealing thread pool with per-thread priority queues.
|
||||
//
|
||||
// Each worker owns a priority_queue (max-heap by priority, FIFO within equal
|
||||
// priority via sequence number). submit() distributes via round-robin. When a
|
||||
// worker's queue is empty it tries to steal from the most-loaded peer using
|
||||
// try_lock to avoid blocking; if no work is found it sleeps on a shared CV.
|
||||
//
|
||||
// total_ counts tasks submitted-but-not-completed (queued + executing).
|
||||
// drain() waits until total_ == 0.
|
||||
|
||||
class ThreadPool : public IScheduler, public IPoolProbe {
|
||||
public:
|
||||
explicit ThreadPool(std::size_t thread_count) : thread_count_(thread_count) {}
|
||||
|
||||
~ThreadPool() {
|
||||
if (!stopped_.load(std::memory_order_relaxed))
|
||||
stop();
|
||||
}
|
||||
|
||||
void start() override {
|
||||
stopped_.store(false, std::memory_order_relaxed);
|
||||
queues_.clear();
|
||||
for (std::size_t i = 0; i < thread_count_; ++i)
|
||||
queues_.push_back(std::make_unique<WorkerQueue>());
|
||||
workers_.reserve(thread_count_);
|
||||
for (std::size_t i = 0; i < thread_count_; ++i)
|
||||
workers_.emplace_back([this, i] { worker_loop(i); });
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stopped_.store(true, std::memory_order_seq_cst);
|
||||
for (auto& q : queues_) {
|
||||
std::lock_guard lock(q->mx);
|
||||
std::size_t discarded = q->pq.size();
|
||||
while (!q->pq.empty()) q->pq.pop();
|
||||
total_.fetch_sub(discarded, std::memory_order_relaxed);
|
||||
}
|
||||
cv_.notify_all();
|
||||
for (auto& t : workers_) if (t.joinable()) t.join();
|
||||
workers_.clear();
|
||||
queues_.clear();
|
||||
}
|
||||
|
||||
void drain() override {
|
||||
std::unique_lock lock(drain_mx_);
|
||||
drain_cv_.wait(lock, [this] {
|
||||
return total_.load(std::memory_order_acquire) == 0;
|
||||
});
|
||||
}
|
||||
|
||||
void submit(std::function<void()> task, float priority = 0.5f) override {
|
||||
std::size_t target = next_.fetch_add(1, std::memory_order_relaxed) % thread_count_;
|
||||
{
|
||||
std::lock_guard lock(queues_[target]->mx);
|
||||
queues_[target]->pq.push(
|
||||
{std::move(task), priority, seq_.fetch_add(1, std::memory_order_relaxed)});
|
||||
}
|
||||
total_.fetch_add(1, std::memory_order_relaxed);
|
||||
submitted_.fetch_add(1, std::memory_order_relaxed);
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
std::size_t thread_count() const { return thread_count_; }
|
||||
|
||||
// ── IPoolProbe ────────────────────────────────────────────────────────────
|
||||
|
||||
PoolSnapshot snapshot(const std::string& name) const override {
|
||||
std::size_t a = active_.load(std::memory_order_relaxed);
|
||||
std::size_t t = total_.load(std::memory_order_relaxed);
|
||||
return {
|
||||
name, thread_count_,
|
||||
t > a ? t - a : 0, // queued (approximate)
|
||||
a, // executing
|
||||
submitted_.load(std::memory_order_relaxed),
|
||||
completed_.load(std::memory_order_relaxed),
|
||||
};
|
||||
}
|
||||
|
||||
private:
|
||||
struct Task {
|
||||
std::function<void()> fn;
|
||||
float priority;
|
||||
uint64_t seq;
|
||||
// max-heap: higher priority runs first; older task wins tie
|
||||
bool operator<(const Task& o) const {
|
||||
if (priority != o.priority) return priority < o.priority;
|
||||
return seq > o.seq;
|
||||
}
|
||||
};
|
||||
|
||||
// Separate cache lines to prevent false sharing between adjacent queues.
|
||||
struct alignas(64) WorkerQueue {
|
||||
std::priority_queue<Task> pq;
|
||||
std::mutex mx;
|
||||
};
|
||||
|
||||
std::optional<std::function<void()>> try_pop(WorkerQueue& q) {
|
||||
std::lock_guard lock(q.mx);
|
||||
if (q.pq.empty()) return std::nullopt;
|
||||
auto fn = std::move(const_cast<Task&>(q.pq.top()).fn);
|
||||
q.pq.pop();
|
||||
return fn;
|
||||
}
|
||||
|
||||
std::optional<std::function<void()>> try_steal(std::size_t thief) {
|
||||
// Find the most-loaded peer without blocking — racy peek is fine.
|
||||
std::size_t victim = thief, best = 0;
|
||||
for (std::size_t i = 0; i < queues_.size(); ++i) {
|
||||
if (i == thief) continue;
|
||||
std::unique_lock lk(queues_[i]->mx, std::try_to_lock);
|
||||
if (!lk) continue;
|
||||
std::size_t n = queues_[i]->pq.size();
|
||||
if (n > best) { best = n; victim = i; }
|
||||
}
|
||||
if (victim == thief) return std::nullopt;
|
||||
return try_pop(*queues_[victim]);
|
||||
}
|
||||
|
||||
void execute(std::function<void()>& fn) {
|
||||
active_.fetch_add(1, std::memory_order_relaxed);
|
||||
fn();
|
||||
completed_.fetch_add(1, std::memory_order_relaxed);
|
||||
active_.fetch_sub(1, std::memory_order_relaxed);
|
||||
// Notify drain() if this was the last in-flight task.
|
||||
// acq_rel ensures the decrement is visible before any drain() load.
|
||||
if (total_.fetch_sub(1, std::memory_order_acq_rel) == 1)
|
||||
drain_cv_.notify_all();
|
||||
}
|
||||
|
||||
void worker_loop(std::size_t id) {
|
||||
while (true) {
|
||||
if (auto fn = try_pop(*queues_[id])) { execute(*fn); continue; }
|
||||
if (auto fn = try_steal(id)) { execute(*fn); continue; }
|
||||
|
||||
std::unique_lock lock(cv_mx_);
|
||||
cv_.wait(lock, [this] {
|
||||
return stopped_.load(std::memory_order_seq_cst)
|
||||
|| total_.load(std::memory_order_relaxed) > 0;
|
||||
});
|
||||
if (stopped_.load(std::memory_order_seq_cst)
|
||||
&& total_.load(std::memory_order_relaxed) == 0)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const std::size_t thread_count_;
|
||||
std::vector<std::unique_ptr<WorkerQueue>> queues_;
|
||||
std::vector<std::thread> workers_;
|
||||
|
||||
std::mutex cv_mx_;
|
||||
std::condition_variable cv_;
|
||||
std::mutex drain_mx_;
|
||||
std::condition_variable drain_cv_;
|
||||
|
||||
std::atomic<bool> stopped_{true};
|
||||
std::atomic<size_t> total_{0}; // queued + executing
|
||||
std::atomic<size_t> active_{0}; // executing only (for snapshot)
|
||||
std::atomic<size_t> next_{0}; // round-robin submit cursor
|
||||
std::atomic<uint64_t> seq_{0}; // tie-break for equal-priority tasks
|
||||
std::atomic<uint64_t> submitted_{0};
|
||||
std::atomic<uint64_t> completed_{0};
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
@@ -2,7 +2,7 @@
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "fanout.hpp"
|
||||
#include "node.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
#include "tmp/fanout_groups.hpp"
|
||||
#include "tmp/topo_sort.hpp"
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
@@ -75,6 +76,10 @@ std::string node_display_name() {
|
||||
return std::string(lbl);
|
||||
} else if constexpr (requires { NodeT::is_fanout_node; NodeT::unique_tag; }) {
|
||||
return "fanout[" + std::to_string(NodeT::unique_tag) + "]";
|
||||
} else if constexpr (requires { NodeT::is_router_node; NodeT::unique_tag; }) {
|
||||
return "router[" + std::to_string(NodeT::unique_tag) + "]";
|
||||
} else if constexpr (requires { NodeT::is_filter_node; NodeT::unique_tag; }) {
|
||||
return "filter[" + std::to_string(NodeT::unique_tag) + "]";
|
||||
} else if constexpr (requires { NodeT::unique_tag; }) {
|
||||
return "node[" + std::to_string(NodeT::unique_tag) + "]";
|
||||
} else {
|
||||
@@ -115,7 +120,7 @@ public:
|
||||
web_debug_port_,
|
||||
[this]() {
|
||||
auto s = collect_snapshots();
|
||||
return web_debug::to_json(s.nodes, s.channels, s.resources, s.elapsed_s);
|
||||
return web_debug::to_json(s.nodes, s.channels, s.resources, s.elapsed_s, s.pools);
|
||||
});
|
||||
web_server_->start();
|
||||
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
|
||||
@@ -123,7 +128,9 @@ public:
|
||||
#endif
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
void stop() override { halt(); }
|
||||
|
||||
void halt() override {
|
||||
stop_flag_ = true;
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_) web_server_->stop();
|
||||
@@ -134,6 +141,22 @@ public:
|
||||
(*it)->stop();
|
||||
}
|
||||
|
||||
// shutdown(): graceful drain in topological order (sources first).
|
||||
// Stops source nodes, polls channels until empty, then stops each downstream layer.
|
||||
void shutdown() override {
|
||||
stop_flag_ = true;
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_) web_server_->stop();
|
||||
#endif
|
||||
// user_nodes_topo_ is already in sources-first order.
|
||||
// Stop each node and drain its output channels before moving on.
|
||||
for (auto* n : user_nodes_topo_) {
|
||||
n->stop();
|
||||
drain_all_channels();
|
||||
}
|
||||
for (auto* n : fanout_nodes_ptr_) n->stop();
|
||||
}
|
||||
|
||||
bool running() const override { return !stop_flag_; }
|
||||
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
@@ -160,6 +183,12 @@ public:
|
||||
resource_probes_.emplace_back(name, probe);
|
||||
}
|
||||
|
||||
// Register a thread pool so it appears in diagnostics and the debug UI.
|
||||
// The probe must outlive this network (typically the pool is on the same stack/shared_ptr).
|
||||
void register_pool(const std::string& name, IPoolProbe* probe) {
|
||||
pool_probes_.emplace_back(name, probe);
|
||||
}
|
||||
|
||||
// Print diagnostics using compile-time node labels
|
||||
void print_diagnostics(std::ostream& os = std::cerr) const {
|
||||
os << "\n┌─ KPN++ StaticNetwork diagnostics ─────────────────────────────\n";
|
||||
@@ -179,6 +208,7 @@ private:
|
||||
std::vector<NodeSnapshot> nodes;
|
||||
std::vector<ChannelSnapshot> channels;
|
||||
std::vector<ResourceSnapshot> resources;
|
||||
std::vector<PoolSnapshot> pools;
|
||||
double elapsed_s;
|
||||
};
|
||||
|
||||
@@ -200,7 +230,23 @@ private:
|
||||
for (auto& [name, probe] : resource_probes_)
|
||||
resources.push_back(probe->snapshot(name));
|
||||
|
||||
return {std::move(nodes), std::move(channels), std::move(resources), elapsed_s};
|
||||
std::vector<PoolSnapshot> pools;
|
||||
for (auto& [name, probe] : pool_probes_)
|
||||
pools.push_back(probe->snapshot(name));
|
||||
|
||||
return {std::move(nodes), std::move(channels), std::move(resources), std::move(pools), elapsed_s};
|
||||
}
|
||||
|
||||
void drain_all_channels() const {
|
||||
bool any_full = true;
|
||||
while (any_full) {
|
||||
any_full = false;
|
||||
for (auto& probe : channel_probes_) {
|
||||
if (probe->snapshot().current_fill > 0) { any_full = true; break; }
|
||||
}
|
||||
if (any_full)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
std::string name_;
|
||||
@@ -212,6 +258,7 @@ private:
|
||||
std::vector<std::string> fanout_node_names_;
|
||||
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
|
||||
std::vector<std::pair<std::string, IResourceProbe*>> resource_probes_;
|
||||
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
|
||||
clock_t::time_point start_time_;
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
uint16_t web_debug_port_{9090};
|
||||
|
||||
@@ -83,4 +83,18 @@ template<typename F>
|
||||
inline constexpr std::size_t output_count_v =
|
||||
std::tuple_size_v<normalised_return_t<return_t<F>>>;
|
||||
|
||||
// ── repeat_tuple: std::tuple<T, T, ..., T> with N repetitions ────────────────
|
||||
|
||||
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
|
||||
struct repeat_tuple;
|
||||
|
||||
template<typename T, std::size_t N, std::size_t... Is>
|
||||
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
|
||||
template<std::size_t> using always_T = T;
|
||||
using type = std::tuple<always_T<Is>...>;
|
||||
};
|
||||
|
||||
template<typename T, std::size_t N>
|
||||
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
@@ -239,11 +239,13 @@ private:
|
||||
};
|
||||
|
||||
// ── PythonConverter ───────────────────────────────────────────────────────────
|
||||
// Specialise for each type you want to use across a PyNetwork.
|
||||
// Required members: to_python(const T&), from_python(nb::object).
|
||||
// Optional member: static constexpr const char* type_name = "friendly_name";
|
||||
// Built-in specialisations for int/float/double/bool/std::string are provided
|
||||
// automatically when you include <kpn/python/auto_bind.hpp>.
|
||||
|
||||
template<typename T>
|
||||
struct PythonConverter {
|
||||
static_assert(sizeof(T) == 0,
|
||||
"PythonConverter<T> must be specialised for each type used in a PyNetwork");
|
||||
};
|
||||
struct PythonConverter {};
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
@@ -47,7 +47,8 @@ static std::pair<std::string,std::string> parse_edge_name(const std::string& nam
|
||||
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
|
||||
const std::vector<ChannelSnapshot>& channels,
|
||||
const std::vector<ResourceSnapshot>& resources = {},
|
||||
double elapsed_s = 0.0) {
|
||||
double elapsed_s = 0.0,
|
||||
const std::vector<PoolSnapshot>& pools = {}) {
|
||||
std::ostringstream o;
|
||||
o << std::fixed;
|
||||
o.precision(2);
|
||||
@@ -97,6 +98,20 @@ static std::string to_json(const std::vector<NodeSnapshot>& nodes,
|
||||
<< ",\"held\":" << (r.held ? "true" : "false")
|
||||
<< "}";
|
||||
}
|
||||
o << "],\"pools\":[";
|
||||
for (std::size_t i = 0; i < pools.size(); ++i) {
|
||||
const auto& p = pools[i];
|
||||
if (i) o << ',';
|
||||
double in_rate = elapsed_s > 0.0 ? p.tasks_submitted / elapsed_s : 0.0;
|
||||
double out_rate = elapsed_s > 0.0 ? p.tasks_completed / elapsed_s : 0.0;
|
||||
o << "{\"name\":\"" << escape_json(p.name) << "\""
|
||||
<< ",\"thread_count\":" << p.thread_count
|
||||
<< ",\"queue_depth\":" << p.queue_depth
|
||||
<< ",\"active_count\":" << p.active_count
|
||||
<< ",\"in_rate\":" << in_rate
|
||||
<< ",\"out_rate\":" << out_rate
|
||||
<< "}";
|
||||
}
|
||||
o << "]}";
|
||||
return o.str();
|
||||
}
|
||||
@@ -164,7 +179,7 @@ const defs = svg.append('defs');
|
||||
const g = svg.append('g');
|
||||
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
|
||||
|
||||
let sim, linkSel, nodeSel, labelSel, edgeLabelSel;
|
||||
let sim, linkSel, nodeSel, labelSel, edgeLabelSel, edgeLabelBwSel;
|
||||
let nodes = [], links = [];
|
||||
|
||||
function edgeColor(fill_pct) {
|
||||
@@ -211,6 +226,10 @@ function init(data) {
|
||||
.attr('class', 'link-label')
|
||||
.text(d => `${d.fill_pct.toFixed(0)}%`);
|
||||
|
||||
edgeLabelBwSel = g.append('g').selectAll('text').data(links).join('text')
|
||||
.attr('class', 'link-label')
|
||||
.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
|
||||
|
||||
// Nodes
|
||||
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
|
||||
.attr('class', 'node')
|
||||
@@ -296,6 +315,7 @@ function update(data) {
|
||||
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
|
||||
.attr('marker-end', d => edgeArrow(d.fill_pct));
|
||||
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
|
||||
edgeLabelBwSel.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
|
||||
}
|
||||
|
||||
function ticked() {
|
||||
@@ -322,6 +342,10 @@ function ticked() {
|
||||
.attr('x', d => (d.source.x + d.target.x)/2)
|
||||
.attr('y', d => (d.source.y + d.target.y)/2 - 6);
|
||||
|
||||
edgeLabelBwSel
|
||||
.attr('x', d => (d.source.x + d.target.x)/2)
|
||||
.attr('y', d => (d.source.y + d.target.y)/2 + 8);
|
||||
|
||||
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user