The spec had drifted far from the code. Key corrections: - Execution model is reactive (PoolNode submits fire_once() to a ThreadPool when inputs are ready), not one blocking thread per node - Channel<T> is a lock-free SPSC ring buffer (atomic wait/notify + spin-before-sleep), not a mutex+CV queue - Remove latch<> ports (never implemented) - NodeErrorHandler returns bool (skip vs stop); per-node - Document new subsystems: scheduler, InterruptNode, Router/FilterNode, MainThreadNode, SharedResource, DebugHub, diagnostics/stats layer - Update StaticNetwork, Python auto_bind layer, examples 01-16 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
790 lines
37 KiB
Markdown
790 lines
37 KiB
Markdown
# KPN++ — Kahn Process Network Library Specification
|
||
|
||
## Overview
|
||
|
||
A header-only C++20 template-metaprogramming library for building Kahn Process Networks. Each
|
||
node wraps a function (or callable object); its input types are inferred from the parameter
|
||
list and its output types from the return type. Nodes communicate over bounded, lock-free
|
||
SPSC FIFO channels.
|
||
|
||
Unlike a naive "one blocking thread per node" model, KPN++ is **reactive**: a node is
|
||
scheduled onto a thread pool whenever all of its input channels have data. A node that wraps a
|
||
function with `Node<>` owns a private single-thread pool and behaves exactly like an
|
||
independent worker; multiple nodes can instead share one `ThreadPool` for bounded-thread
|
||
execution. Source nodes self-resubmit; event-driven sources (`InterruptNode`) fire on an
|
||
external trigger.
|
||
|
||
The library ships rich runtime diagnostics (per-node exec/CPU/throughput stats, per-channel
|
||
fill/bandwidth/overflow counters, pool and shared-resource utilisation), an optional in-process
|
||
web debug UI, and nanobind-based Python bindings (partially implemented).
|
||
|
||
> **Note on accuracy.** This document describes the code as it exists in `include/kpn/`. Where
|
||
> a behaviour is subtle the relevant header is named so the source remains the ground truth.
|
||
|
||
---
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
kpn++/
|
||
├── CMakeLists.txt
|
||
├── include/kpn/
|
||
│ ├── fixed_string.hpp # NTTP string + in<>/out<> tags + index_of
|
||
│ ├── traits.hpp # function signature introspection, normalised_return_t, repeat_tuple
|
||
│ ├── diagnostics.hpp # NodeStats, ChannelStats, *Snapshot, IPoolProbe, IResourceProbe
|
||
│ ├── channel.hpp # lock-free SPSC ring-buffer Channel<T> + storage policy
|
||
│ ├── port.hpp # InputPort / OutputPort handles
|
||
│ ├── inode.hpp # INode interface, NodeErrorHandler, NodeEvent
|
||
│ ├── scheduler.hpp # IScheduler + work-stealing ThreadPool
|
||
│ ├── pool_node.hpp # PoolNode / PoolObjectNode (reactive, scheduler-driven)
|
||
│ ├── interrupt_node.hpp # InterruptNode (external-trigger source)
|
||
│ ├── node.hpp # Node / ObjectNode (PoolNode + private 1-thread pool) + make_node
|
||
│ ├── fanout.hpp # FanoutNode<T,N> + make_fanout
|
||
│ ├── branch.hpp # RouterNode<T,N> + FilterNode<T> + make_router / make_filter
|
||
│ ├── shared_resource.hpp # SharedResource<T> priority-arbitrated exclusive resource
|
||
│ ├── main_thread_node.hpp # MainThreadNode<> (GUI / main-thread-bound nodes)
|
||
│ ├── static_network.hpp # Edge<>, make_network(), StaticNetwork<>
|
||
│ ├── network.hpp # runtime Network builder + watchdog + diagnostics
|
||
│ ├── debug_hub.hpp # DebugHub multi-network web UI (KPN_WEB_DEBUG only)
|
||
│ ├── web_debug.hpp # single-network web debug server (KPN_WEB_DEBUG only)
|
||
│ ├── variant_node.hpp # runtime-typed nodes/channels for Python graphs
|
||
│ ├── tmp/
|
||
│ │ ├── fanout_groups.hpp # compile-time fan-out detection + edge expansion
|
||
│ │ ├── topo_sort.hpp # compile-time DFS cycle check + topological order
|
||
│ │ └── repeat_tuple.hpp # repeat_tuple_t<T,N>
|
||
│ ├── python/
|
||
│ │ ├── bindings.hpp # PyNetwork / PyNode nanobind helpers
|
||
│ │ └── auto_bind.hpp # NodeRegistry / Entry / bind_network / bind_debug
|
||
│ └── kpn.hpp # umbrella header
|
||
├── src/network.cpp
|
||
├── tests/ # Catch2 v3 + GoogleTest
|
||
├── examples/ # 01–16 (see Examples)
|
||
├── benchmarks/ # bench_pipeline (optional, KPN_BUILD_BENCHMARKS)
|
||
└── python/kpn_python.cpp # nanobind module definition
|
||
```
|
||
|
||
`kpn.hpp` is the umbrella header; including it pulls in the full C++ API (the Python layer is
|
||
included only by the binding TU).
|
||
|
||
---
|
||
|
||
## Component 0 — `fixed_string.hpp`: NTTP String + Port Tags
|
||
|
||
Named ports use C++20 non-type template parameters (NTTPs). `std::string_view` and
|
||
`const char*` are not valid NTTPs, so a `fixed_string` literal type provides `constexpr`
|
||
internal storage.
|
||
|
||
```cpp
|
||
template<std::size_t N>
|
||
struct fixed_string {
|
||
char data[N]{};
|
||
constexpr fixed_string(const char (&s)[N]) { std::copy_n(s, N, data); }
|
||
constexpr bool operator==(const fixed_string&) const = default;
|
||
constexpr std::string_view view() const { return {data, N - 1}; }
|
||
};
|
||
|
||
template<std::size_t N>
|
||
fixed_string(const char (&)[N]) -> fixed_string<N>; // deduction guide (required)
|
||
```
|
||
|
||
`fixed_string<4>` and `fixed_string<7>` are distinct types, so `input<"img">()` and
|
||
`input<"sigma">()` produce different instantiations — enabling zero-overhead compile-time
|
||
port dispatch.
|
||
|
||
Named-port lookup uses a `constexpr` `index_of` over the name pack; it returns the sentinel
|
||
`npos` on a miss so the `static_assert` fires at the `input<"img">()` **call site**, giving a
|
||
readable error at the point of use:
|
||
|
||
```cpp
|
||
inline constexpr std::size_t npos = std::size_t(-1);
|
||
|
||
template<fixed_string Name, fixed_string... Names>
|
||
constexpr std::size_t index_of(); // returns position or npos
|
||
```
|
||
|
||
### Port tags
|
||
|
||
`in<...>` and `out<...>` tag types disambiguate input vs. output name packs in the factory
|
||
API. Both are trivial empty structs; both are optional (omit to get index-only ports).
|
||
|
||
```cpp
|
||
template<fixed_string... Names> struct in {};
|
||
template<fixed_string... Names> struct out {};
|
||
```
|
||
|
||
> There is **no `latch<>` tag.** An earlier design sketched latched (most-recent-value)
|
||
> input ports; this was not implemented and the only input kind is the synchronous one.
|
||
|
||
---
|
||
|
||
## Component 1 — `traits.hpp`: Function Introspection
|
||
|
||
Extracts parameter and return types from any callable at compile time, for free functions,
|
||
function pointers, member function pointers (const and non-const), lambdas and `std::function`.
|
||
|
||
```cpp
|
||
// function_traits<F>::return_t, ::args (std::tuple<...>), ::arity
|
||
template<typename F> using return_t = ...; // return type
|
||
template<typename F> using args_t = ...; // std::tuple of parameters
|
||
template<typename F> inline constexpr std::size_t arity_v = ...;
|
||
```
|
||
|
||
The return type is normalised to a tuple so every node has a uniform output-tuple shape:
|
||
|
||
```cpp
|
||
// void → std::tuple<> (sink node, 0 outputs)
|
||
// T (non-tup) → std::tuple<T> (1 output)
|
||
// tuple<...> → tuple<...> (one output port per element)
|
||
template<typename T> using normalised_return_t = ...;
|
||
template<typename F> inline constexpr std::size_t output_count_v = ...;
|
||
```
|
||
|
||
`repeat_tuple_t<T, N>` (also surfaced via `tmp/repeat_tuple.hpp`) builds `std::tuple<T, …, T>`
|
||
with `N` repetitions — used by `FanoutNode` and `RouterNode` to describe their N identical
|
||
output ports.
|
||
|
||
---
|
||
|
||
## Component 2 — `diagnostics.hpp`: Statistics and Snapshots
|
||
|
||
Shared timing types: `clock_t = std::chrono::steady_clock`, `duration_t` is a
|
||
`double`-millisecond duration.
|
||
|
||
- **`NodeStats`** — atomic counters updated per fire: `frames_processed`, an EMA of wall-clock
|
||
exec time (`ema_exec_us`, warmup-mean for the first 5 frames then α=0.1), `max_exec_us`,
|
||
`total_blocked_us`, thread CPU time (`total_cpu_us` via `CLOCK_THREAD_CPUTIME_ID`),
|
||
`queue_wait_us` (pool queue latency), and `exec_start_us` (non-zero while executing; used by
|
||
the watchdog to detect hung nodes).
|
||
- **`ChannelStats`** — `pushes`, `bytes_pushed`, `drops`, `overflows`, `pops`, `peak_fill`.
|
||
- **Snapshots** — copyable plain structs taken by the watchdog / UI: `NodeSnapshot`,
|
||
`ChannelSnapshot` (with `fill_pct()`, `peak_pct()`, `bandwidth_mbs()`), `PoolSnapshot`,
|
||
`ResourceSnapshot`, and `NetworkSnapshot` (used by the `DebugHub`).
|
||
- **Probe interfaces** — `IPoolProbe` and `IResourceProbe` expose a `snapshot(name)` method so
|
||
pools and shared resources can be registered with a network for reporting.
|
||
|
||
### `ChannelDataSize<T>` trait
|
||
|
||
`bytes_pushed` is computed from a specialisable trait, defaulting to `sizeof(T)`. Specialise it
|
||
for heap-owning payloads to get accurate bandwidth:
|
||
|
||
```cpp
|
||
template<> struct kpn::ChannelDataSize<cv::Mat> {
|
||
static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Component 3 — `channel.hpp`: Lock-free Bounded FIFO + Storage Policy
|
||
|
||
### Storage policy
|
||
|
||
The type stored inside a channel is chosen by a specialisable trait. Small trivially-copyable
|
||
types are stored by value; everything else as `std::shared_ptr<const T>` so fan-out copies a
|
||
refcount, not data:
|
||
|
||
```cpp
|
||
template<typename T>
|
||
struct channel_storage_policy {
|
||
static constexpr bool by_value =
|
||
std::is_trivially_copyable_v<T> && sizeof(T) <= 8;
|
||
};
|
||
|
||
template<typename T>
|
||
using channel_storage_t = std::conditional_t<
|
||
channel_storage_policy<T>::by_value, T, std::shared_ptr<const T>>;
|
||
```
|
||
|
||
Override it to force value semantics for a custom small type. Push wraps a value in
|
||
`make_shared<const T>` when needed; pop dereferences it transparently, so a function taking
|
||
`const T&` works naturally and immutability is compiler-enforced.
|
||
|
||
### Channel — SPSC ring buffer
|
||
|
||
`Channel<T>` is a single-producer/single-consumer ring buffer (capacity rounded up to a power
|
||
of two). It uses C++20 `std::atomic::wait/notify_one` (portable futex) with a configurable
|
||
**spin-before-sleep** window so the common case never touches the kernel.
|
||
|
||
```cpp
|
||
template<typename T>
|
||
class Channel {
|
||
public:
|
||
using storage_type = channel_storage_t<T>;
|
||
|
||
explicit Channel(std::size_t capacity = 5, std::size_t spin_count = 200);
|
||
|
||
void push(T value); // drops if disabled; throws ChannelOverflowError if full
|
||
bool push_sentinel(T value); // out-of-band, non-blocking must-deliver token (EOF)
|
||
T pop(); // blocks (spin then futex); throws ChannelClosedError if disabled+empty
|
||
bool try_pop(T& out, std::chrono::milliseconds timeout); // polling (watchdog/display)
|
||
bool try_pop_now(T& out); // immediate, non-blocking
|
||
|
||
void enable(); // accept pushes
|
||
void disable(); // stop accepting + unblock any waiting pop()
|
||
void set_push_callback(std::function<void()>); // empty→non-empty notification
|
||
|
||
std::size_t size() const; // ring occupancy (excludes any pending sentinel)
|
||
std::size_t approx_size() const; // size() + 1 if a sentinel is pending (readiness checks)
|
||
std::size_t capacity() const;
|
||
bool is_accepting() const;
|
||
const ChannelStats& stats() const;
|
||
ChannelSnapshot snapshot(const std::string& name) const;
|
||
};
|
||
|
||
class ChannelOverflowError : public std::runtime_error { /* capacity + optional context */ };
|
||
class ChannelClosedError : public std::runtime_error {};
|
||
```
|
||
|
||
`head_` and `tail_`/`wake_` live on separate cache lines (`alignas(64)`) to avoid false
|
||
sharing between producer and consumer. `spin_hint()` issues a `pause`/`yield` instruction (or a
|
||
compiler fence on other ISAs).
|
||
|
||
### The `push_callback` — how reactivity works
|
||
|
||
`set_push_callback` registers a callback fired when a channel transitions empty→non-empty. A
|
||
consuming `PoolNode` installs this on each of its input channels; when an input becomes ready it
|
||
re-evaluates whether **all** inputs have data and, if so, submits itself to the scheduler. This
|
||
is the mechanism that replaces a dedicated blocking thread per node.
|
||
|
||
### The out-of-band EOF sentinel — `push_sentinel`
|
||
|
||
`push_sentinel(T value)` delivers a **must-deliver control token** (a graceful-EOF marker) that
|
||
cannot be dropped by backpressure. The value is stored in a dedicated slot **outside** the ring,
|
||
so it consumes no capacity, never throws `ChannelOverflowError`, and never blocks the producer.
|
||
|
||
This matters because a node's worker cannot afford to block on a downstream push: parking that
|
||
thread would stop it draining its own input, cascading into a hold-and-wait deadlock under
|
||
backpressure. `push_sentinel` sets a published flag (`has_eof_`) and returns immediately, keeping
|
||
the worker free to keep popping.
|
||
|
||
Ordering is preserved: the consumer's `pop()` / `try_pop_now()` drain the ring **first** and only
|
||
surface the sentinel once the ring is observed empty — so EOF always arrives after every value
|
||
pushed before it. `approx_size()` (used by node readiness checks) counts a pending sentinel as one
|
||
consumable item, so a channel carrying *only* a sentinel still schedules its consumer's next fire
|
||
and the token is never stranded. Same SPSC contract as `push()` (sole producer); returns `false`
|
||
if the channel is already disabled (teardown in progress → the token is moot).
|
||
|
||
### Backpressure and shutdown — `accepting_` flag
|
||
|
||
Each channel carries `std::atomic<bool> accepting_` (default `true`). It is the primary shutdown
|
||
mechanism; the only additional signal is the out-of-band EOF sentinel above, used for *graceful*
|
||
drain rather than an abrupt close.
|
||
|
||
- **`push()`** on a disabled channel silently drops the value (recorded as a `drop`). On a
|
||
full accepting channel it throws `ChannelOverflowError` (a sizing error).
|
||
- **`pop()`** blocks while empty and accepting; `disable()` wakes it and it throws
|
||
`ChannelClosedError`.
|
||
|
||
The **consumer node** owns its input channels and flips the flag: `start()` calls `enable()`,
|
||
`stop()` calls `disable()`. Producers never touch it.
|
||
|
||
### Ownership
|
||
|
||
Input channels are owned by their **consumer node** (held as `shared_ptr<Channel<T>>`). A
|
||
producer node holds a non-owning raw `Channel<T>*` to push into. `Network`/`StaticNetwork` are
|
||
otherwise non-owning of user nodes — see Components 8–9.
|
||
|
||
---
|
||
|
||
## Component 4 — `inode.hpp`: The Node Interface
|
||
|
||
Every node implements `INode`:
|
||
|
||
```cpp
|
||
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) = 0;
|
||
|
||
virtual void set_network_overflow_callback(NodeEventCallback) {} // network-injected
|
||
virtual void set_network_closed_callback(NodeEventCallback) {}
|
||
|
||
virtual void halt() { stop(); } // immediate, discard in-flight work
|
||
virtual void shutdown() { stop(); } // graceful topo-ordered drain (overridden by networks)
|
||
};
|
||
```
|
||
|
||
Supporting types:
|
||
|
||
```cpp
|
||
// Per-node error policy: return true to skip the failed fire and keep running,
|
||
// false to stop the node (and signal closed downstream).
|
||
using NodeErrorHandler = std::function<bool(std::string_view node_name, std::exception_ptr)>;
|
||
|
||
using NodeEventCallback = std::function<void(std::chrono::steady_clock::time_point)>;
|
||
enum class NodeEvent { Overflow, Closed };
|
||
```
|
||
|
||
---
|
||
|
||
## Component 5 — `scheduler.hpp`: Thread Pool
|
||
|
||
```cpp
|
||
struct IScheduler {
|
||
virtual void submit(std::function<void()> task, float priority = 0.5f) = 0;
|
||
virtual void start() = 0;
|
||
virtual void stop() = 0; // join workers, discard pending tasks
|
||
virtual void drain() = 0; // block until in-flight tasks complete (workers keep running)
|
||
};
|
||
```
|
||
|
||
`ThreadPool` is a **work-stealing** pool implementing both `IScheduler` and `IPoolProbe`. Each
|
||
worker owns a priority queue (max-heap by `priority`, FIFO within equal priority via a sequence
|
||
counter). `submit()` distributes round-robin; idle workers steal from the most-loaded peer
|
||
using `try_lock`, then sleep on a shared condition variable. The submit/notify path takes the CV
|
||
mutex around `notify` to close the lost-wakeup window; `drain()` waits on a separate counter of
|
||
in-flight tasks. `priority` lets a hot node (full input, empty output) be scheduled ahead of
|
||
others — see `PoolNode::compute_priority`.
|
||
|
||
---
|
||
|
||
## Component 6 — Node Types
|
||
|
||
All processing nodes share the same shape: typed input channels they own, raw output-channel
|
||
pointers set at wiring time, `args_tuple` / `return_tuple` aliases used by the connect-time
|
||
type check, and `static constexpr` `label()` / `unique_tag` / `input_count` / `output_count`.
|
||
|
||
### `PoolNode` / `PoolObjectNode` — reactive, scheduler-driven (`pool_node.hpp`)
|
||
|
||
The core node. Instead of a blocked thread, it submits a `fire_once()` to a shared
|
||
`IScheduler` whenever all inputs are ready; `queued_` ensures at most one `fire_once()` is
|
||
in flight. `fire_once()` pops every input (`try_pop_now`), runs the function, pushes each
|
||
normalised output, records stats, then resubmits if inputs remain ready. Source nodes
|
||
(`input_count == 0`) self-submit on `start()` and after each fire.
|
||
|
||
```cpp
|
||
template<auto Func,
|
||
typename InputTag = in<>,
|
||
typename OutputTag = out<>,
|
||
fixed_string Label = "",
|
||
std::size_t UniqueTag = 0>
|
||
class PoolNode : public INode { ... };
|
||
|
||
auto n = make_pool_node<func>(scheduler, fifo_capacity); // index ports
|
||
auto n = make_pool_node<func, "label", 0>(scheduler, in<"a">{}, out<"b">{}, cap);
|
||
```
|
||
|
||
`PoolObjectNode<Obj, …>` is the same for a stateful callable object (introspected via
|
||
`&Obj::operator()`); the object must outlive the node.
|
||
|
||
Per-node configuration: `set_error_handler(NodeErrorHandler)`, `set_overflow_callback`,
|
||
`set_closed_callback`, `set_max_exec_time`. Inside `fire_once()`:
|
||
`ChannelOverflowError` fires the overflow callbacks; `ChannelClosedError` (or an error handler
|
||
returning `false`) fires the closed callbacks and self-stops; any other exception consults the
|
||
error handler.
|
||
|
||
**Name-count contract** — a `static_assert` requires that the number of input names is `0` or
|
||
equals arity (same for outputs):
|
||
|
||
```cpp
|
||
static_assert(sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
||
"make_pool_node: number of input names must match function arity, or provide none");
|
||
```
|
||
|
||
### `Node` / `ObjectNode` — convenience wrappers (`node.hpp`)
|
||
|
||
`Node<>` privately owns a `ThreadPool(1)` and derives from `PoolNode<>` with the **same**
|
||
template signature, so each `Node` is a self-contained worker with no external scheduler. Its
|
||
`start()`/`stop()` start and stop the private pool around the base. This keeps the simple API —
|
||
`make_node<func>(5)` — while routing all execution through the one `fire_once()` code path.
|
||
|
||
```cpp
|
||
template<auto Func, typename InputTag = in<>, typename OutputTag = out<>,
|
||
fixed_string Label = "", std::size_t UniqueTag = 0>
|
||
class Node : public PoolNode<...> { ... };
|
||
|
||
auto src = make_node<produce>(5);
|
||
auto dbl = make_node<double_it, "dbl">(5);
|
||
auto cnt = make_node<count_words>(in<"words">{}, out<"count","words">{}, 4);
|
||
```
|
||
|
||
The `Label` NTTP gives a human-readable name for diagnostics; `UniqueTag` is a collision-breaker
|
||
required when the **same function** is used as two distinct vertices in a `StaticNetwork` (two
|
||
`make_node<blur>` would otherwise be the same type). Both default so existing code is unaffected.
|
||
To share one pool across many nodes for bounded-thread execution, use `make_pool_node` directly.
|
||
|
||
### `InterruptNode` — external-trigger source (`interrupt_node.hpp`)
|
||
|
||
A zero-input source driven by an external event (camera frame, timer, socket) instead of
|
||
self-resubmission. `get_trigger()` returns a thread-safe callable to hand to the event source;
|
||
each call increments a `pending_` counter and submits `fire_once()` on the 0→1 transition,
|
||
guaranteeing one execution per trigger even under bursts. It does not busy-loop.
|
||
|
||
```cpp
|
||
auto cam = make_interrupt_node<grab_frame>(scheduler, out<"frame">{});
|
||
camera_sdk.on_frame_ready(cam.get_trigger());
|
||
```
|
||
|
||
### `FanoutNode<T, N>` — explicit fan-out (`fanout.hpp`)
|
||
|
||
Reads one item and pushes a copy to each of N outputs (per-output overflow drops
|
||
independently). Runs on its own `std::jthread` blocking on `pop()`. Used directly in a runtime
|
||
`Network` via `make_fanout<T,N>`, and auto-inserted by `make_network()` for `StaticNetwork`.
|
||
|
||
### `RouterNode<T, N>` / `FilterNode<T>` — branching (`branch.hpp`)
|
||
|
||
Both run on a dedicated `jthread`. `RouterNode` pushes each item to exactly **one** of N
|
||
outputs chosen by a `selector(item) -> size_t` (out-of-range index drops). `FilterNode` forwards
|
||
an item only when `pred(item)` is true. Factories: `make_router<T,N>(sel)`, `make_filter<T>(pred)`.
|
||
|
||
### `MainThreadNode<Derived, in<…>, Args…>` — GUI / main-thread nodes (`main_thread_node.hpp`)
|
||
|
||
For work that *must* run on the thread owning a GUI event loop (OpenCV `imshow`/`waitKey` on
|
||
Wayland/Qt). It owns input channels and is registered as a normal `INode` (appears in
|
||
diagnostics) but spawns **no** thread. The application drives it by calling `step()` in a loop on
|
||
the main thread: `step()` does a zero-timeout `try_pop` on every input, and when all are ready
|
||
invokes the derived `operator()(Args…)` (returning `false` to stop). CRTP; the derived class
|
||
supplies the operator.
|
||
|
||
---
|
||
|
||
## Component 7 — `shared_resource.hpp`: Priority-arbitrated Exclusive Resource
|
||
|
||
`SharedResource<T>` wraps a singleton-like resource (an ONNX session, a CUDA stream) shared by
|
||
nodes across one or more networks, and arbitrates access with a **priority + aging** waiter
|
||
queue. Priority is re-evaluated at every release (so it reflects current queue state), and each
|
||
waiter's effective score grows with wait time (`kAgingPerSecond`) to prevent starvation.
|
||
|
||
```cpp
|
||
SharedResource<OrtSession> res(session_args...);
|
||
|
||
// inside a node functor:
|
||
auto guard = res.acquire_balanced(in_channel, out_channel); // RAII; releases on scope exit
|
||
guard->Run(...);
|
||
```
|
||
|
||
`acquire_balanced(in, out)` scores a waiter by `input_fill × output_headroom` — a node with a
|
||
full input queue and empty output is most urgent. `acquire(fn)` takes any `()->float` priority;
|
||
`acquire()` treats all waiters equally. Implements `IResourceProbe` so it shows up in
|
||
diagnostics and the debug hub. The factory is `make_shared_resource<T>(args…)`.
|
||
|
||
---
|
||
|
||
## Component 8 — `network.hpp`: Runtime Graph Builder + Watchdog
|
||
|
||
`Network` is **non-owning** (nodes outlive it; `add()` stores `INode*`). A builder collects the
|
||
full topology before `build()`, enabling cycle detection and topological ordering.
|
||
|
||
```cpp
|
||
class Network : public INode {
|
||
public:
|
||
template<typename NodeT> Network& add(std::string name, NodeT& node);
|
||
|
||
template<typename SrcNode, std::size_t SrcIdx, typename DstNode, std::size_t DstIdx>
|
||
Network& connect(const std::string& src, OutputPort<SrcNode, SrcIdx>,
|
||
const std::string& dst, InputPort<DstNode, DstIdx>);
|
||
|
||
Network& expose_input (std::string boundary_name, InputPort<NodeT, Idx>); // sub-network port
|
||
Network& expose_output(std::string boundary_name, OutputPort<NodeT, Idx>);
|
||
|
||
Network& build(); // DFS cycle check (throws NetworkCycleError) + topo sort
|
||
|
||
void start() override; // start nodes in topo order; launch watchdog (+ web UI)
|
||
void stop() override; // == halt()
|
||
void halt() override; // immediate: stop nodes in reverse topo order
|
||
void shutdown() override; // graceful: stop source layers, drain channels, descend
|
||
|
||
void set_watchdog_interval(std::chrono::milliseconds);
|
||
void set_error_handler(ErrorHandler); // void(node_name, exception_ptr)
|
||
void set_diagnostics_handler(DiagnosticsHandler); // fired each watchdog tick
|
||
void set_event_handler(EventHandler); // void(name, NodeEvent, timestamp)
|
||
void register_pool(const std::string&, IPoolProbe*);
|
||
|
||
void print_diagnostics(std::ostream& = std::cerr) const; // formatted table
|
||
};
|
||
```
|
||
|
||
- **`connect`** static-asserts that the source output type equals the destination input type
|
||
(via the nodes' `return_tuple` / `args_tuple`), sets the consumer's input channel as the
|
||
producer's output pointer, registers a `ChannelProbe` for diagnostics, and rejects a second
|
||
connection from the same output port (use `make_fanout`).
|
||
- **`build`** colours the graph DFS; a back-edge throws `NetworkCycleError`. It also wires each
|
||
node's network-level overflow/closed callbacks to the `EventHandler` if one is set.
|
||
- **`halt` vs `shutdown`** — `halt()` disables channels and stops nodes in reverse order
|
||
immediately; `shutdown()` walks source layers first, polling channel probes until they drain
|
||
before stopping the next layer.
|
||
- **Watchdog** — a `std::jthread` that wakes on `watchdog_interval_` (default 3 s), collects
|
||
snapshots, warns about nodes whose `exec_start_us` indicates an execution running > 5 s, and
|
||
either calls the diagnostics handler or prints the formatted report.
|
||
- **`expose_input`/`expose_output`** record boundary names (sub-network support is scaffolded;
|
||
`Network` is itself an `INode` and can be `add()`ed to an outer `Network`).
|
||
|
||
The formatted report includes node (frames, exec ms, max ms, blocked ms, fps, cpu ms, util%),
|
||
channel (fill%, peak%, pushes, drops, overflow, MB/s, item bytes), and pool tables, plus a
|
||
bottleneck hint (highest `ema_exec_ms`).
|
||
|
||
---
|
||
|
||
## Component 9 — `static_network.hpp`: Compile-time Graph Builder
|
||
|
||
For C++ graphs whose full topology is known at compile time. The complete edge list is a type
|
||
pack, so fan-out arity is known up front, cycle detection is a `static_assert`, and start/stop
|
||
are pointer-vector traversals rather than string-map + virtual dispatch.
|
||
|
||
```cpp
|
||
// edge() builds a typed Edge descriptor from two port handles.
|
||
template<typename SrcNode, std::size_t SrcIdx, typename DstNode, std::size_t DstIdx>
|
||
Edge<SrcNode, SrcIdx, DstNode, DstIdx>
|
||
edge(OutputPort<SrcNode, SrcIdx>, InputPort<DstNode, DstIdx>);
|
||
|
||
// make_network() takes all edges, expands fan-outs, wires channels, returns a StaticNetwork.
|
||
template<typename... Edges> auto make_network(Edges&&... edges);
|
||
```
|
||
|
||
Usage — no `add`/`connect`/`build`/string names; one source port feeding two destinations
|
||
auto-inserts a `FanoutNode`:
|
||
|
||
```cpp
|
||
auto src = make_node<produce, "src">(8);
|
||
auto blur = make_node<blur_func, "blur">(8);
|
||
auto detect = make_node<detect_func,"detect">(8);
|
||
auto sink = make_node<display, "sink">(8);
|
||
|
||
auto net = make_network(
|
||
edge(src.output<0>(), blur.input<0>()),
|
||
edge(src.output<0>(), detect.input<0>()), // same source port → FanoutNode<T,2> inserted
|
||
edge(blur.output<0>(), sink.input<0>()),
|
||
edge(detect.output<0>(), sink.input<1>()));
|
||
net.start(); /* … */ net.stop();
|
||
```
|
||
|
||
`make_network` performs, at compile time: fan-out detection and edge expansion
|
||
(`tmp/fanout_groups.hpp`), a duplicate-`(Func, UniqueTag)` check
|
||
(`static_assert` — "add a UniqueTag"), and a cycle check + topological order
|
||
(`tmp/topo_sort.hpp`, `static_assert` — "graph contains a directed cycle"). At run time it
|
||
heap-allocates owned `FanoutNode` storage, collects user-node pointers in edge order, sets each
|
||
node's display name (`Label`, else `node[UniqueTag]`; fan-outs become `"<src>_fanout"`), wires
|
||
every expanded edge, and builds channel probes.
|
||
|
||
`StaticNetwork<FanoutStorage, TopoNodeList>` implements `INode` (so it can be embedded in a
|
||
runtime `Network`). It owns the fan-out nodes, holds user nodes by pointer, and provides
|
||
`start`/`halt`/`shutdown`, an `EventHandler`, `register_resource` / `register_pool`,
|
||
`print_diagnostics`, and `network_snapshot()` (consumed by the `DebugHub`). Compile-time labels
|
||
are read from each `NodeType::label()`.
|
||
|
||
> `make_fanout<T,N>` remains for explicit fan-out in a runtime `Network`; `make_network` users
|
||
> never call it.
|
||
|
||
---
|
||
|
||
## Component 10 — Web Debugging (optional, `KPN_WEB_DEBUG`)
|
||
|
||
Zero cost when disabled — guarded headers, no symbols, no dependency. Depends on **cpp-httplib**
|
||
(single-header, fetched by CMake when the option is on) and loads **D3.js v7** from CDN. Enable
|
||
per-target:
|
||
|
||
```cpp
|
||
#define KPN_WEB_DEBUG 1
|
||
#include <kpn/kpn.hpp>
|
||
```
|
||
|
||
### Single-network server (`web_debug.hpp`)
|
||
|
||
When enabled, `Network` / `StaticNetwork` gain `set_web_debug_port(uint16_t)` (default 9090) and
|
||
auto-start an in-process HTTP server in `start()`. It serves an inline single-page app at `/` and
|
||
a JSON snapshot at `/api/snapshot` (nodes, channels/edges, pools, resources, elapsed). The page
|
||
renders a force-directed graph: node colour encodes `ema_exec_ms`, edge colour encodes fill%,
|
||
with hover tooltips for the full stat set; it polls every 500 ms.
|
||
|
||
### `DebugHub` — multi-network UI (`debug_hub.hpp`)
|
||
|
||
A standalone server aggregating several networks under one endpoint:
|
||
|
||
```cpp
|
||
DebugHub hub(9090);
|
||
hub.register_network("detect", detect_net); // disables that net's own server
|
||
hub.register_network("classify", classify_net);
|
||
hub.register_resource("gpu", &gpu_resource); // shows utilisation cards
|
||
hub.start();
|
||
```
|
||
|
||
The hub UI has one tab per registered network plus an "All Networks" tab with shared-resource
|
||
cards and a cross-network node table. `register_network` calls `net.disable_web_server()` so the
|
||
hub is the single debug endpoint; call it before `net.start()`.
|
||
|
||
---
|
||
|
||
## Component 11 — Python Bindings (partial)
|
||
|
||
> Status: scaffolded and partially implemented. The variant machinery, `PyNetwork`/`PyNode`, and
|
||
> the auto-binding layer exist; the demo module wires a hello-pipeline. Full sub-port read/write
|
||
> and mixed C++/Python graphs are still in progress.
|
||
|
||
Python graphs cannot resolve types at compile time, so a `PyNetwork` is parameterised by a
|
||
`std::variant` derived (at compile time, via `unique_types`) from the port types of a **closed
|
||
list of registered C++ node types**. The variant only appears at the C++/Python boundary; each
|
||
node's internal `Channel<T>` still stores raw `T` (`variant_node.hpp`: `IVariantChannel`,
|
||
`VariantChannel<T,Variant>`, `IVariantNode`, `VariantNodeWrapper`).
|
||
|
||
### Auto-binding (`python/auto_bind.hpp`)
|
||
|
||
The node list is declared once with a `NodeRegistry` of `Entry<func, "name">`. `bind_network`
|
||
registers the `PyNetwork` class, a `make_<name>(capacity)` factory and a `<Name>Node` class per
|
||
entry, and auto-registers `PythonConverter` for each port type. `bind_debug` additionally exposes
|
||
each raw C++ function as a free Python callable for testing without a network. Recompiling the
|
||
extension is the registration step — there is no CMake code-gen.
|
||
|
||
```cpp
|
||
using DemoNodes = kpn::python::NodeRegistry<
|
||
kpn::python::Entry<produce, "produce">,
|
||
kpn::python::Entry<double_it, "double_it">,
|
||
kpn::python::Entry<print_it, "print_it">>; // variant auto-deduced as std::variant<int>
|
||
|
||
NB_MODULE(kpn_python, m) {
|
||
bind_network<DemoNodes>(m);
|
||
bind_debug<DemoNodes>(m);
|
||
}
|
||
```
|
||
|
||
Custom types are supported by specialising `kpn::PythonConverter<T>` (`to_python` / `from_python`,
|
||
optional `type_name`) before `bind_network`.
|
||
|
||
### GIL rules (non-negotiable)
|
||
|
||
1. **Acquire for callback** — hold the GIL only for the duration of a Python callable
|
||
invocation (`nb::gil_scoped_acquire` around the call site).
|
||
2. **Release while blocking** — release the GIL before any blocking channel op
|
||
(`nb::gil_scoped_release`), then re-acquire. Violating this deadlocks: a PyNode thread
|
||
waiting for the GIL cannot proceed while another thread holds it and blocks on a channel
|
||
waiting for that PyNode.
|
||
|
||
---
|
||
|
||
## Error Handling Contract
|
||
|
||
| Situation | Behaviour |
|
||
|---|---|
|
||
| FIFO overflow (full, accepting) | `ChannelOverflowError` thrown in producer; node overflow callbacks fire |
|
||
| Push to a disabled channel | Value silently dropped (counted as a `drop`) |
|
||
| Node function throws | Routed to the node's `NodeErrorHandler` → `true` skips & continues, `false` stops the node |
|
||
| Node stopped / channel closed | `ChannelClosedError` → node fires closed callbacks and self-stops |
|
||
| Type mismatch (C++) | `static_assert` at `connect()` / `make_network()` |
|
||
| Cycle in graph (runtime) | `NetworkCycleError` thrown at `build()` |
|
||
| Cycle in graph (static) | `static_assert` at `make_network()` |
|
||
| Duplicate `(Func, UniqueTag)` (static) | `static_assert` at `make_network()` — add a `UniqueTag` |
|
||
| Hung node | Watchdog warning after threshold |
|
||
|
||
`Network` additionally exposes an aggregate `EventHandler(name, NodeEvent, timestamp)` for
|
||
overflow/closed events across all nodes.
|
||
|
||
---
|
||
|
||
## Thread Model
|
||
|
||
KPN++ is **reactive**, not one-thread-per-node:
|
||
|
||
- A `PoolNode` owns no thread. It registers a push-callback on each input channel; when all
|
||
inputs are ready it submits `fire_once()` to a shared `IScheduler` (a `ThreadPool`).
|
||
- `Node<>` wraps a `PoolNode` plus a **private `ThreadPool(1)`**, recovering "independent
|
||
worker" semantics with the simple `make_node` API. Many nodes can instead share one pool
|
||
(`make_pool_node`) for a bounded OS thread count.
|
||
- `FanoutNode`, `RouterNode`, and `FilterNode` do run a dedicated `std::jthread` blocking on
|
||
`pop()` (they are simple, latency-sensitive routers).
|
||
- `InterruptNode` fires on an external trigger; `MainThreadNode` runs on the caller's main
|
||
thread via `step()`.
|
||
|
||
`std::jthread` (C++20) and its `stop_token` are used where a thread is owned, simplifying
|
||
cooperative shutdown. Benchmarks (`benchmarks/bench_pipeline`) show ~2–7 µs/hop framework
|
||
overhead for chains within the core count, rising under oversubscription.
|
||
|
||
---
|
||
|
||
## Platform and Compiler Requirements
|
||
|
||
C++20 is required.
|
||
|
||
| Feature | Min compiler |
|
||
|---|---|
|
||
| NTTP structural types (`fixed_string`) | GCC 11, Clang 13, MSVC 19.29 |
|
||
| `std::atomic::wait/notify` (channel futex) | GCC 11, Clang 13, MSVC 19.29 |
|
||
| `std::jthread` + `stop_token` | GCC 11, Clang 14, MSVC 19.29 |
|
||
| `auto` NTTPs, fold expressions, `if constexpr`, concepts | C++20 / C++17 baseline |
|
||
|
||
`CLOCK_THREAD_CPUTIME_ID` (per-thread CPU stats in `diagnostics.hpp`) is POSIX. nanobind
|
||
requires Python 3.8+ (auto-fetched when `KPN_BUILD_PYTHON=ON`).
|
||
|
||
---
|
||
|
||
## Testing Strategy
|
||
|
||
**Catch2 v3** for behaviour/integration tests and **GoogleTest** for unit and death tests; both
|
||
are auto-fetched. Existing suites: `test_fixed_string`, `test_traits`, `test_channel`,
|
||
`test_node`, `test_network`, `test_static_network`, `test_scheduler`, `test_pool_node`,
|
||
`test_shared_resource`.
|
||
|
||
Cases covered explicitly include: channel blocking/unblocking and overflow; shutdown races
|
||
(`stop()` while blocked on `pop()`); `try_pop_now`; fan-out delivery; tuple unpacking to
|
||
sub-channels; runtime cycle detection and static cycle/duplicate-tag `static_assert`s; named
|
||
port lookup and wrong-name-count `static_assert`s; storage-policy by-value vs `shared_ptr`;
|
||
scheduler submit/steal/drain; `PoolNode` reactive scheduling; and `SharedResource` priority +
|
||
aging.
|
||
|
||
---
|
||
|
||
## Examples
|
||
|
||
Self-contained programs under `examples/`, built by default (`-DKPN_BUILD_EXAMPLES=OFF` to
|
||
skip). They double as documentation and smoke tests.
|
||
|
||
| Example | What it shows |
|
||
|---|---|
|
||
| `01_hello_pipeline` | Linear pipeline, index-based wiring, `Network` builder |
|
||
| `02_named_ports` | `in<>`/`out<>` tags, named port access, wrong-name `static_assert` |
|
||
| `03_multi_output` | Tuple-returning node, per-element sub-port routing |
|
||
| `04_storage_policy` | `channel_storage_policy` default + specialisation |
|
||
| `05_error_handling` | `ChannelOverflowError`, diagnostics handler |
|
||
| `06_watchdog` | Watchdog interval, stall detection |
|
||
| `07_python_network` | `PyNetwork` with a pure-Python node *(pending)* |
|
||
| `08_python_subport` | `net.read` / `net.write`, sub-port tap *(pending)* |
|
||
| `09_opencv_cellshade` | Real-time cell-shading on webcam; named ports, fan-out, `MainThreadNode` display (requires OpenCV) |
|
||
| `10_static_hello_pipeline` | `make_network()` version of 01 — compile-time topology |
|
||
| `11_static_fanout` | Auto-inserted `FanoutNode` from a duplicated source port |
|
||
| `12_static_cellshade` | Static cell-shading with auto fan-out and `Label` NTTPs |
|
||
| `13_debug_cellshade` | One-op-per-node pipeline + variadic `DebugCanvas<N>` tiling node |
|
||
| `14_debug_hub` | Two networks sharing a `SharedResource` via `DebugHub` |
|
||
| `15_node_error_handler` | Per-node `set_error_handler` (skip-and-continue vs stop) |
|
||
| `16_event_callbacks` | `set_overflow_callback` + network `set_event_handler` |
|
||
|
||
---
|
||
|
||
## Future Extension Points (Heterogeneous Execution)
|
||
|
||
Not implemented, but the design keeps these doors open:
|
||
|
||
- **`IChannel` abstract interface** — `Channel<T>` and a future `RemoteChannel<T>` (socket /
|
||
shared-memory) sharing one `push`/`pop` surface so nodes are agnostic to channel location.
|
||
- **`Serializer<T>` trait** — parallel to `channel_storage_policy` / `PythonConverter`, for
|
||
cross-device serialisation (MessagePack for embedded, pinned memory for GPU zero-copy).
|
||
- **`NodeKind` tag** — e.g. `{ Local, Gpu, Remote }` on `INode`, letting the watchdog apply
|
||
per-device health-check and timeout strategies.
|
||
|
||
The `IScheduler` abstraction already decouples node execution from any specific thread model,
|
||
making a cooperative or device-specific executor a drop-in.
|
||
|
||
---
|
||
|
||
## Resolved Design Decisions
|
||
|
||
| Question | Decision |
|
||
|---|---|
|
||
| Execution model | Reactive: nodes submit `fire_once()` to an `IScheduler` when inputs are ready, not one blocking thread per node |
|
||
| `Node<>` vs `PoolNode<>` | `Node<>` owns a private `ThreadPool(1)`; `PoolNode<>` shares a pool for bounded threads |
|
||
| Channel | Lock-free SPSC ring buffer, `atomic::wait/notify` + spin-before-sleep |
|
||
| Shutdown | Per-channel `accepting_` flag; `disable()` unblocks `pop()` (→ `ChannelClosedError`) |
|
||
| Overflow | `ChannelOverflowError` on full accepting channel; silent drop on disabled channel |
|
||
| Node error policy | Per-node `NodeErrorHandler` returning bool (skip vs stop) |
|
||
| Network ownership | Non-owning; user declares nodes, network stores `INode*` |
|
||
| Fan-out | Explicit `FanoutNode<T,N>` for runtime `Network`; auto-inserted by `make_network()` |
|
||
| Branching | `RouterNode<T,N>` (select one of N) and `FilterNode<T>` (predicate gate) |
|
||
| Static vs runtime graph | Both; `StaticNetwork` for compile-time C++ topology, `Network` for dynamic/Python; `StaticNetwork` is an `INode` so it embeds in `Network` |
|
||
| Node identity (static graphs) | `Label` NTTP (name) + `UniqueTag` NTTP (collision-breaker); both default |
|
||
| Shared device resource | `SharedResource<T>` with priority + aging arbitration |
|
||
| Main-thread / GUI work | `MainThreadNode<>` driven by `step()` on the main thread |
|
||
| External-event sources | `InterruptNode` with a thread-safe `get_trigger()` |
|
||
| Web debugging | Per-network server + multi-network `DebugHub`, behind `KPN_WEB_DEBUG` |
|
||
| Mixed-rate latched inputs | **Not implemented** — no `latch<>` ports |
|