diff --git a/CMakeLists.txt b/CMakeLists.txt index a1fe93c..dd147b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,12 @@ if(KPN_BUILD_TESTS) add_subdirectory(tests) endif() +# ── Benchmarks ──────────────────────────────────────────────────────────────── +option(KPN_BUILD_BENCHMARKS "Build benchmarks" OFF) +if(KPN_BUILD_BENCHMARKS) + add_subdirectory(benchmarks) +endif() + # ── Python bindings ─────────────────────────────────────────────────────────── if(KPN_BUILD_PYTHON) find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED) @@ -69,3 +75,14 @@ endif() if(KPN_BUILD_EXAMPLES) add_subdirectory(examples) endif() + +# ── Docs (README generation) ────────────────────────────────────────────────── +find_package(Python3 QUIET COMPONENTS Interpreter) +if(Python3_FOUND) + add_custom_target(docs + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/render_readme.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + COMMENT "Rendering README.md from README.md.in" + VERBATIM + ) +endif() diff --git a/README.md b/README.md index 9ab76d3..124f044 100644 --- a/README.md +++ b/README.md @@ -50,31 +50,55 @@ A node wraps any callable. Its input types are taken from the function's paramet ```cpp #include using namespace kpn; +``` -// Single input, single output -int double_it(int x) { return x * 2; } +Source, transform, and sink — from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp): -// Multi-output — must return std::tuple -std::tuple split(cv::Mat frame) { ... } +```cpp +static int produce() { return 42; } +static int double_it(int x) { return x * 2; } +static void print_it(int x) { std::cout << "result: " << x << '\n'; } +``` -// Sink — void return, no output ports -void display(cv::Mat frame) { cv::imshow("out", frame); } +Multi-output node returning a tuple — from [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp): + +```cpp +// Multi-output: returns (key, value) as a tuple — KPN++ routes each element +// to its own output port automatically. +static std::tuple parse(std::string kv) { + auto sep = kv.find('='); + if (sep == std::string::npos) return {kv, ""}; + return {kv.substr(0, sep), kv.substr(sep + 1)}; +} ``` ### Creating Nodes +**Index-only ports** (from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp)): + ```cpp -// No port names (index-only access) -auto node = make_node(/*fifo_capacity=*/5); +auto src = make_node(5); +auto dbl = make_node(5); +auto sink = make_node(5); +``` -// Named input ports only -auto node = make_node(in<"value">{}, 5); +**Named ports** (from [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp)): -// Named input and output ports -auto node = make_node(in<"value">{}, out<"result">{}, 5); +```cpp +// tokenise: no inputs, one named output "words" +auto tok = make_node(out<"words">{}, 4); -// Named output ports only (e.g. a source with no inputs) -auto node = make_node(out<"colour","grey">{}, 5); +// count_words: named input "words", named outputs "count" and "words" +auto cnt = make_node(in<"words">{}, out<"count", "words">{}, 4); + +// report: two named inputs +auto snk = make_node(in<"count", "words">{}, 4); +``` + +**Multi-named output source** (from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp)): + +```cpp +auto src = make_node(out<"colour","grey">{}, 8); ``` Port names are NTTP `fixed_string` values — resolved entirely at compile time, zero runtime cost. @@ -83,22 +107,25 @@ Port names are NTTP `fixed_string` values — resolved entirely at compile time, `Network` is **non-owning** — declare nodes first, then register them. Nodes must outlive the network. -```cpp -auto src = make_node(in<"x">{}, out<"value">{}, 5); -auto proc = make_node(in<"value">{}, out<"result">{}, 5); +From [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp): +```cpp Network net; -net.add("src", src) - .add("proc", proc) - .connect("src", src.output<0>(), "proc", proc.input<0>()) // by index - .connect("src", src.template output<"value">(), "proc", proc.template input<"value">()) // by name - .build(); // runs cycle detection — throws NetworkCycleError on cycles +net.add("tok", tok) + .add("cnt", cnt) + .add("snk", snk) + .connect("tok", tok.template output<"words">(), "cnt", cnt.template input<"words">()) + .connect("cnt", cnt.template output<"count">(), "snk", snk.template input<"count">()) + .connect("cnt", cnt.template output<"words">(), "snk", snk.template input<"words">()) + .build(); net.start(); -// ... do work ... +std::this_thread::sleep_for(std::chrono::milliseconds(500)); net.stop(); ``` +`.build()` runs cycle detection — throws `NetworkCycleError` on cycles. + > **Named port syntax in template context:** when the node variable is `auto`-deduced, use `.template output<"name">()` and `.template input<"name">()` to help the parser. ### Channel Semantics @@ -113,14 +140,36 @@ net.stop(); Large types (`sizeof > 8` or non-trivially-copyable) are stored as `std::shared_ptr` inside the channel — no copies, shared immutable ownership. Small trivially-copyable types are stored by value. -Override the policy for a specific type: +Override the policy for a specific type (from [`examples/04_storage_policy/main.cpp`](examples/04_storage_policy/main.cpp)): ```cpp -template<> struct kpn::channel_storage_policy { +// Override: store Tag by value despite being a struct +// (it's trivially copyable and small — this just makes the policy explicit) +template<> +struct kpn::channel_storage_policy { static constexpr bool by_value = true; }; ``` +### Diagnostics & Error Handling + +Custom diagnostics handler — fires on the watchdog interval (from [`examples/05_error_handling/main.cpp`](examples/05_error_handling/main.cpp)): + +```cpp +// Custom diagnostics handler — fires on the watchdog interval. +// Print a concise one-liner rather than the full table. +net.set_diagnostics_handler([](const std::vector& nodes, + const std::vector& channels) { + std::cout << "[diag] "; + for (auto& n : nodes) + std::cout << n.name << "=" << n.throughput_fps << "fps "; + for (auto& c : channels) + std::cout << "channel fill=" << static_cast(c.fill_pct()) << "% " + << "overflows=" << c.overflows; + std::cout << '\n'; +}); +``` + ### Shutdown `node.stop()` / `net.stop()`: @@ -171,20 +220,155 @@ top.start(); ## Display / GUI Nodes -**Do not wrap `imshow`/`waitKey` as a KPN node.** Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, wire the final output channel to a `Channel` and pop it on the main thread: +**Do not wrap `imshow`/`waitKey` as a KPN node.** Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, derive from `MainThreadNode<>` — it owns the input channels, implements `INode`, and exposes a `step()` method to call on the main thread. + +`DisplayNode` from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp): ```cpp -Channel result_ch(8); -comp.set_output_channel<0>(&result_ch); -// ... build and start network ... +class DisplayNode : public kpn::MainThreadNode, + cv::Mat, cv::Mat> { +public: + DisplayNode() : MainThreadNode(8) { + cv::namedWindow("Cell Shade", cv::WINDOW_NORMAL); + cv::namedWindow("Edge Mask", cv::WINDOW_NORMAL); + cv::resizeWindow("Cell Shade", 1280, 720); + cv::resizeWindow("Edge Mask", 640, 360); + } + + ~DisplayNode() { cv::destroyAllWindows(); } + + bool operator()(cv::Mat composite, cv::Mat edges) { + cv::imshow("Cell Shade", composite); + cv::Mat edges_bgr; + cv::cvtColor(edges, edges_bgr, cv::COLOR_GRAY2BGR); + cv::imshow("Edge Mask", edges_bgr); + int key = cv::waitKey(1); + if (key == 'q' || key == 27) return false; + return window_open("Cell Shade") && window_open("Edge Mask"); + } + +private: + static bool window_open(const char* name) { + try { return cv::getWindowProperty(name, cv::WND_PROP_VISIBLE) >= 1; } + catch (const cv::Exception&) { return false; } + } +}; +``` + +Wire it into the network and drive it from the main thread: + +```cpp +net.start(); + +// Main thread drives display — imshow/waitKey stay on the GUI thread. +// step() returns false when operator() returns false (q pressed / window closed). +while (disp.step()) + cv::waitKey(8); // yield event loop when no frame ready + +net.stop(); +``` + +--- + +## OpenCV Cell-Shading Example + +Real-time cell-shading pipeline from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp). + +**Source node** — returns two frames (colour + grey) as a tuple, routing them to separate downstream branches: + +```cpp +static std::tuple capture() { + constexpr int W = 640, H = 480; + static cv::VideoCapture cap; + static bool opened = false; + if (!opened) { + opened = true; + cap.open(0, cv::CAP_V4L2); + if (cap.isOpened()) { + cap.set(cv::CAP_PROP_FRAME_WIDTH, W); + cap.set(cv::CAP_PROP_FRAME_HEIGHT, H); + } else { + std::cerr << "[capture] no webcam — using synthetic animated pattern\n"; + } + } -// Main thread display loop -while (true) { cv::Mat frame; - if (!result_ch.try_pop(frame, std::chrono::milliseconds(100))) continue; - cv::imshow("output", frame); - if (cv::waitKey(1) == 'q') break; + if (cap.isOpened()) { + auto t0 = std::chrono::steady_clock::now(); + cap >> frame; + auto elapsed = std::chrono::steady_clock::now() - t0; + if (elapsed < std::chrono::milliseconds(20)) + std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed); + if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3); + } else { + static int tick = 0; + static cv::Mat grad = make_gradient(W, H); + ++tick; + frame = grad.clone(); + int r = 150 + (tick % 80) * 4; + cv::circle(frame, {W/2, H/2}, r, {255, 200, 0}, -1); + cv::circle(frame, {W/2, H/2}, r / 2, { 0, 128, 255}, -1); + cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200, 0, 200}, -1); + std::this_thread::sleep_for(std::chrono::milliseconds(33)); + } + return {frame.clone(), frame.clone()}; } +``` + +**Full network wiring:** + +```cpp +auto src = make_node (out<"colour","grey">{}, 8); +auto gray_node = make_node (in<"bgr">{}, out<"gray">{}, 8); +auto edge_node = make_node (in<"gray">{}, out<"edges">{}, 8); +auto quant = make_node (in<"bgr">{}, out<"quantised">{}, 8); +auto comp = make_node(in<"edges","colour">{}, out<"result","edges">{}, 8); + +// DisplayNode: two windows opened in constructor, step() drives main thread. +DisplayNode disp; + +Network net; +net.add("src", src) + .add("gray", gray_node) + .add("edges", edge_node) + .add("quant", quant) + .add("comp", comp) + .add("display", disp) + .connect("src", src.template output<"colour">(), "quant", quant.template input<"bgr">()) + .connect("quant", quant.template output<"quantised">(), "comp", comp.template input<"colour">()) + .connect("src", src.template output<"grey">(), "gray", gray_node.template input<"bgr">()) + .connect("gray", gray_node.template output<"gray">(), "edges", edge_node.template input<"gray">()) + .connect("edges", edge_node.template output<"edges">(), "comp", comp.template input<"edges">()) + .connect("comp", comp.template output<"result">(), "display", disp.template input<"composite">()) + .connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">()) + .build(); +``` + +--- + +## Fan-Out (Multi-Output) + +From [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp) — one node fans out to two independent sinks via a tuple return: + +```cpp +auto gen = make_node(out<"kv">{}, 4); +auto par = make_node (in<"kv">{}, out<"key", "value">{}, 4); +auto keys = make_node (in<"key">{}, 4); +auto vals = make_node(in<"value">{}, 4); + +Network net; +net.add("gen", gen) + .add("par", par) + .add("keys", keys) + .add("vals", vals) + .connect("gen", gen.template output<"kv">(), "par", par.template input<"kv">()) + .connect("par", par.template output<"key">(), "keys", keys.template input<"key">()) + .connect("par", par.template output<"value">(), "vals", vals.template input<"value">()) + .build(); + +net.start(); +std::this_thread::sleep_for(std::chrono::milliseconds(600)); net.stop(); ``` @@ -228,6 +412,143 @@ Run the cell-shading example: --- +## Performance + +Measured on Linux (x86-64, `-O3 -march=native`) with `benchmarks/bench_pipeline`. +Each topology pushes N items through the graph; `overhead_us/item` strips out the +per-node compute time to isolate framework cost. + +Overhead formula: `(elapsed − (N + depth − 1) × work_us) / N` removes the expected +pipeline-fill cost so the number reflects pure framework latency. + +### Baseline overhead (private pools, 100 µs/node) + +| Topology | items/sec | overhead µs/item | +|---|---|---| +| chain depth-1 | 9 797 | ~2 | +| chain depth-4 | 9 448 | ~4 | +| chain depth-8 | 9 078 | ~7 | +| chain depth-16 | 7 004 | ~13 ← oversubscription | +| chain depth-32 | 4 179 | ~77 ← oversubscription | +| wide fanout-1 | 9 751 | ~3 | +| wide fanout-4 | 9 668 | ~3 | +| diamond (2×2) | 9 607 | ~4 | + +Chain overhead is flat at **~2–7 µs/hop** for depths within the machine's core count, +then rises once threads compete for CPU. Wide and diamond topologies add no measurable +overhead as fanout increases — all branches run in parallel. + +### Scheduling modes + +`Node<>` gives each node a private `ThreadPool(1)`. `PoolNode<>` lets multiple +nodes share one pool. The right choice depends on the graph shape: + +| Scenario | Recommended | +|---|---| +| Work per node < 100 µs, deep chain | Private pools — lower per-hop latency | +| Work per node ≥ 100 µs, wide/diamond | Shared pool, `threads = hardware_concurrency` | +| Any graph, bounded thread count required | Shared pool, `threads ≥ max parallel nodes` | + +A shared single-thread pool (`threads=1`) fully serialises the graph — throughput +divides by depth for chains and by width for fanout topologies. A shared pool with +`threads ≥ max_concurrent_nodes` matches private-pool throughput while keeping the +OS thread count bounded. + +### vs. TBB flow graph + +Benchmarked against `tbb::flow::function_node` (serial concurrency) with +`tbb::flow::broadcast_node` for fanout. Run with `cmake -DKPN_BUILD_BENCHMARKS=ON` +— TBB benchmarks are included automatically when `find_package(TBB)` succeeds. + +**Channel implementation:** lock-free SPSC ring buffer with `std::atomic::wait/notify_one` +(C++20 portable futex) plus a configurable spin-before-sleep window (default ~4 µs). +Large types are stored as `shared_ptr` — fanout copies reference counts, +not data. + +Overhead µs/item at **work_us = 10** (framework overhead dominates): + +| Topology | KPN private | TBB | +|---|---|---| +| chain depth-1 | 1.7 | **1.4** | +| chain depth-4 | 2.5 | **2.2** | +| chain depth-8 | **3.0** | 3.6 | +| chain depth-16 | **9.3** | 13.0 | +| chain depth-32 | 23.2 | **14.2** | +| wide fanout-4 | 2.5 | **1.4** | +| diamond (2×2) | 3.4 | **1.9** | + +Overhead µs/item at **work_us = 100** (moderate compute, KPN wins): + +| Topology | KPN private | TBB | +|---|---|---| +| chain depth-1 | **2.1** | 3.5 | +| chain depth-4 | **4.3** | 5.2 | +| chain depth-8 | **6.7** | 8.5 | +| chain depth-16 | **12.8** | 17.4 | +| chain depth-32 | **77** | 81 | +| wide fanout-4 | 3.4 | **1.9** | +| diamond (2×2) | **4.1** | 6.1 | + +KPN private pools beat TBB for every chain and diamond topology at 100 µs/node, and +match TBB within ~20% at 10 µs/node for shallow chains. TBB retains an edge on wide +fanout (serial dispatch loop vs. work-stealing pool) and at extreme oversubscription +depths (chain-32 at 10 µs). The remaining gap at light work is the cost of +`atomic::wait` vs. TBB's continuously-spinning worker threads. + +### vs. TBB — API + +The function signature is the node. KPN infers input and output types automatically; +there is no graph object to manage. + +**Single-output node:** +```cpp +// KPN — 1 line +int scale(int x) { return x * 2; } + +// TBB — must state types, concurrency policy, and carry a graph reference +tbb::flow::function_node n(g, tbb::flow::serial, [](int x){ return x*2; }); +``` + +**Multi-output node:** +```cpp +// KPN — return a tuple +std::tuple split(cv::Mat f) { return {f, f}; } + +// TBB — multifunction_node + explicit try_put per port +tbb::flow::multifunction_node> n( + g, tbb::flow::serial, + [](cv::Mat f, auto& ports) { + std::get<0>(ports).try_put(f); + std::get<1>(ports).try_put(f); + }); +``` + +**Named ports** — compile-time checked, zero runtime cost, not available in TBB: +```cpp +auto node = make_node(in<"frame">{}, out<"colour","grey">{}, 5); +net.connect("cam", cam.output<"frame">(), "split", node.input<"frame">()); +// ^^^^^^^ typo → compile error +``` + +| | KPN | TBB | +|---|---|---| +| Node definition | plain function | `function_node` + explicit types | +| Multi-output | `return std::tuple` | `multifunction_node` + `try_put` × N | +| Named ports | `in<"name">` / `out<"name">` compile-time | none | +| Graph lifetime | none | `graph g` must outlive all nodes | +| Shutdown | `net.stop()` | `g.wait_for_all()` + manual | +| Python bindings | designed-in | none | + +Build the benchmarks with: + +```bash +cmake -B build -DKPN_BUILD_BENCHMARKS=ON +cmake --build build --target bench_pipeline +./build/benchmarks/bench_pipeline | tee results.csv +``` + +--- + ## Project Structure ``` @@ -254,4 +575,6 @@ python/ kpn_python.cpp — nanobind module entry point examples/ 01_hello_pipeline/ … 09_opencv_cellshade/ +scripts/ + render_readme.py — regenerates README.md from README.md.in ``` diff --git a/README.md.in b/README.md.in new file mode 100644 index 0000000..1883aae --- /dev/null +++ b/README.md.in @@ -0,0 +1,402 @@ +# KPN++ + +A C++20 Kahn Process Network (KPN) library. Each node wraps a function and runs in its own thread, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind. + +--- + +## Requirements + +| Dependency | Version | Notes | +|---|---|---| +| CMake | ≥ 3.21 | | +| C++ compiler | GCC ≥ 11, Clang ≥ 13, MSVC 19.29 | C++20 required | +| Threads | system | `find_package(Threads)` | +| nanobind | ≥ 2.1 | auto-fetched if not installed; Python ≥ 3.8 | +| Catch2 | v3 | auto-fetched for tests | +| Google Test | v1.14 | auto-fetched for tests | +| OpenCV | ≥ 4 | optional; only for example 09 | + +--- + +## Build + +```bash +cmake -B build -DKPN_BUILD_PYTHON=OFF # core + tests + C++ examples +cmake --build build --parallel +ctest --test-dir build +``` + +Enable Python bindings (requires nanobind and Python dev headers): + +```bash +cmake -B build -DKPN_BUILD_PYTHON=ON +cmake --build build --parallel +``` + +Disable examples: + +```bash +cmake -B build -DKPN_BUILD_EXAMPLES=OFF +``` + +--- + +## Core Concepts + +### Nodes + +A node wraps any callable. Its input types are taken from the function's parameter list; its output types from the return type. Multi-output nodes return `std::tuple<...>`. + +```cpp +#include +using namespace kpn; +``` + +Source, transform, and sink — from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp): + + + +Multi-output node returning a tuple — from [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp): + + + +### Creating Nodes + +**Index-only ports** (from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp)): + + + +**Named ports** (from [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp)): + + + +**Multi-named output source** (from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp)): + +```cpp +auto src = make_node(out<"colour","grey">{}, 8); +``` + +Port names are NTTP `fixed_string` values — resolved entirely at compile time, zero runtime cost. + +### Building a Network + +`Network` is **non-owning** — declare nodes first, then register them. Nodes must outlive the network. + +From [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp): + + + +`.build()` runs cycle detection — throws `NetworkCycleError` on cycles. + +> **Named port syntax in template context:** when the node variable is `auto`-deduced, use `.template output<"name">()` and `.template input<"name">()` to help the parser. + +### Channel Semantics + +- **Bounded FIFO**: default capacity 5, configurable per-node at construction. +- **Blocking `pop()`**: consumer blocks until data is available (KPN semantics). +- **Throwing `push()`**: throws `ChannelOverflowError` if the channel is full and accepting. +- **Silent drop on disabled channel**: after `node.stop()`, its input channels are disabled — producers that push into them have the value silently dropped. No exception, no blocking. +- **Source throttling**: source nodes (no inputs) must sleep or yield to avoid overflowing downstream FIFOs. See example 09. + +### Storage Policy + +Large types (`sizeof > 8` or non-trivially-copyable) are stored as `std::shared_ptr` inside the channel — no copies, shared immutable ownership. Small trivially-copyable types are stored by value. + +Override the policy for a specific type (from [`examples/04_storage_policy/main.cpp`](examples/04_storage_policy/main.cpp)): + + + +### Diagnostics & Error Handling + +Custom diagnostics handler — fires on the watchdog interval (from [`examples/05_error_handling/main.cpp`](examples/05_error_handling/main.cpp)): + + + +### Shutdown + +`node.stop()` / `net.stop()`: +1. Sets `accepting_ = false` on all input channels (drops in-flight pushes silently). +2. Clears any queued items from those channels. +3. Unblocks any thread blocked on `pop()` (throws `ChannelClosedError` inside `run_loop`, which exits cleanly). +4. Joins the node thread. + +--- + +## Named Ports — Design Notes + +Port names use C++20 NTTP `fixed_string`. The deduction guide is required: + +```cpp +template +fixed_string(const char (&)[N]) -> fixed_string; +``` + +`fixed_string<4>` and `fixed_string<7>` are distinct types — `input<"img">()` and `input<"sigma">()` resolve to different template instantiations at compile time. Wrong names produce a `static_assert` at the call site with a readable message. + +--- + +## Sub-Networks + +`Network` implements `INode`, so it can be nested inside a larger `Network`: + +```cpp +// Inner sub-network +Network pipe; +pipe.add("pre", pre_node) + .add("enh", enh_node) + .connect("pre", pre_node.output<0>(), "enh", enh_node.input<0>()) + .expose_input("img", pre_node.input<0>()) + .expose_output("result", enh_node.output<0>()) + .build(); + +// Outer network +Network top; +top.add("pipe", pipe) + .add("sink", sink_node) + .connect("pipe", pipe.output<"result">(), "sink", sink_node.input<0>()) + .build(); +top.start(); +``` + +--- + +## Display / GUI Nodes + +**Do not wrap `imshow`/`waitKey` as a KPN node.** Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, derive from `MainThreadNode<>` — it owns the input channels, implements `INode`, and exposes a `step()` method to call on the main thread. + +`DisplayNode` from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp): + + + +Wire it into the network and drive it from the main thread: + + + +--- + +## OpenCV Cell-Shading Example + +Real-time cell-shading pipeline from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp). + +**Source node** — returns two frames (colour + grey) as a tuple, routing them to separate downstream branches: + + + +**Full network wiring:** + + + +--- + +## Fan-Out (Multi-Output) + +From [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp) — one node fans out to two independent sinks via a tuple return: + + + +--- + +## Python Bindings + +> Python bindings are scaffolded but not yet fully implemented. See `python/kpn_python.cpp` and `include/kpn/python/bindings.hpp`. + +A `PyNetwork` is constructed from a closed list of C++ node types. The variant of all port types is derived at compile time — no runtime type registration needed. + +**GIL rules (non-negotiable):** +- Acquire the GIL only for the duration of a Python callable invocation. +- Release the GIL before any blocking channel operation (`pop()`, `push()`, `net.read()`, `net.write()`). + +Violating the second rule deadlocks. + +--- + +## Examples + +| Example | What it shows | +|---|---| +| `01_hello_pipeline` | Linear pipeline, index-based port wiring | +| `02_named_ports` | `in<>`/`out<>` name tags, named port access | +| `03_multi_output` | Tuple-returning node, per-element sub-port routing | +| `04_storage_policy` | `channel_storage_policy` default and specialisation | +| `05_error_handling` | `ChannelOverflowError`, `ErrorHandler` | +| `06_watchdog` | Watchdog interval, stall detection | +| `07_python_network` | PyNetwork, pure Python node *(pending)* | +| `08_python_subport` | `net.read`, `net.write`, sub-port tap *(pending)* | +| `09_opencv_cellshade` | Real-time cell-shading on webcam/pattern; requires OpenCV ≥ 4 | + +Run the cell-shading example: + +```bash +./build/examples/09_opencv_cellshade +# Press 'q' or close the window to stop. +# Falls back to an animated synthetic pattern if no webcam is found. +``` + +--- + +## Performance + +Measured on Linux (x86-64, `-O3 -march=native`) with `benchmarks/bench_pipeline`. +Each topology pushes N items through the graph; `overhead_us/item` strips out the +per-node compute time to isolate framework cost. + +Overhead formula: `(elapsed − (N + depth − 1) × work_us) / N` removes the expected +pipeline-fill cost so the number reflects pure framework latency. + +### Baseline overhead (private pools, 100 µs/node) + +| Topology | items/sec | overhead µs/item | +|---|---|---| +| chain depth-1 | 9 797 | ~2 | +| chain depth-4 | 9 448 | ~4 | +| chain depth-8 | 9 078 | ~7 | +| chain depth-16 | 7 004 | ~13 ← oversubscription | +| chain depth-32 | 4 179 | ~77 ← oversubscription | +| wide fanout-1 | 9 751 | ~3 | +| wide fanout-4 | 9 668 | ~3 | +| diamond (2×2) | 9 607 | ~4 | + +Chain overhead is flat at **~2–7 µs/hop** for depths within the machine's core count, +then rises once threads compete for CPU. Wide and diamond topologies add no measurable +overhead as fanout increases — all branches run in parallel. + +### Scheduling modes + +`Node<>` gives each node a private `ThreadPool(1)`. `PoolNode<>` lets multiple +nodes share one pool. The right choice depends on the graph shape: + +| Scenario | Recommended | +|---|---| +| Work per node < 100 µs, deep chain | Private pools — lower per-hop latency | +| Work per node ≥ 100 µs, wide/diamond | Shared pool, `threads = hardware_concurrency` | +| Any graph, bounded thread count required | Shared pool, `threads ≥ max parallel nodes` | + +A shared single-thread pool (`threads=1`) fully serialises the graph — throughput +divides by depth for chains and by width for fanout topologies. A shared pool with +`threads ≥ max_concurrent_nodes` matches private-pool throughput while keeping the +OS thread count bounded. + +### vs. TBB flow graph + +Benchmarked against `tbb::flow::function_node` (serial concurrency) with +`tbb::flow::broadcast_node` for fanout. Run with `cmake -DKPN_BUILD_BENCHMARKS=ON` +— TBB benchmarks are included automatically when `find_package(TBB)` succeeds. + +**Channel implementation:** lock-free SPSC ring buffer with `std::atomic::wait/notify_one` +(C++20 portable futex) plus a configurable spin-before-sleep window (default ~4 µs). +Large types are stored as `shared_ptr` — fanout copies reference counts, +not data. + +Overhead µs/item at **work_us = 10** (framework overhead dominates): + +| Topology | KPN private | TBB | +|---|---|---| +| chain depth-1 | 1.7 | **1.4** | +| chain depth-4 | 2.5 | **2.2** | +| chain depth-8 | **3.0** | 3.6 | +| chain depth-16 | **9.3** | 13.0 | +| chain depth-32 | 23.2 | **14.2** | +| wide fanout-4 | 2.5 | **1.4** | +| diamond (2×2) | 3.4 | **1.9** | + +Overhead µs/item at **work_us = 100** (moderate compute, KPN wins): + +| Topology | KPN private | TBB | +|---|---|---| +| chain depth-1 | **2.1** | 3.5 | +| chain depth-4 | **4.3** | 5.2 | +| chain depth-8 | **6.7** | 8.5 | +| chain depth-16 | **12.8** | 17.4 | +| chain depth-32 | **77** | 81 | +| wide fanout-4 | 3.4 | **1.9** | +| diamond (2×2) | **4.1** | 6.1 | + +KPN private pools beat TBB for every chain and diamond topology at 100 µs/node, and +match TBB within ~20% at 10 µs/node for shallow chains. TBB retains an edge on wide +fanout (serial dispatch loop vs. work-stealing pool) and at extreme oversubscription +depths (chain-32 at 10 µs). The remaining gap at light work is the cost of +`atomic::wait` vs. TBB's continuously-spinning worker threads. + +### vs. TBB — API + +The function signature is the node. KPN infers input and output types automatically; +there is no graph object to manage. + +**Single-output node:** +```cpp +// KPN — 1 line +int scale(int x) { return x * 2; } + +// TBB — must state types, concurrency policy, and carry a graph reference +tbb::flow::function_node n(g, tbb::flow::serial, [](int x){ return x*2; }); +``` + +**Multi-output node:** +```cpp +// KPN — return a tuple +std::tuple split(cv::Mat f) { return {f, f}; } + +// TBB — multifunction_node + explicit try_put per port +tbb::flow::multifunction_node> n( + g, tbb::flow::serial, + [](cv::Mat f, auto& ports) { + std::get<0>(ports).try_put(f); + std::get<1>(ports).try_put(f); + }); +``` + +**Named ports** — compile-time checked, zero runtime cost, not available in TBB: +```cpp +auto node = make_node(in<"frame">{}, out<"colour","grey">{}, 5); +net.connect("cam", cam.output<"frame">(), "split", node.input<"frame">()); +// ^^^^^^^ typo → compile error +``` + +| | KPN | TBB | +|---|---|---| +| Node definition | plain function | `function_node` + explicit types | +| Multi-output | `return std::tuple` | `multifunction_node` + `try_put` × N | +| Named ports | `in<"name">` / `out<"name">` compile-time | none | +| Graph lifetime | none | `graph g` must outlive all nodes | +| Shutdown | `net.stop()` | `g.wait_for_all()` + manual | +| Python bindings | designed-in | none | + +Build the benchmarks with: + +```bash +cmake -B build -DKPN_BUILD_BENCHMARKS=ON +cmake --build build --target bench_pipeline +./build/benchmarks/bench_pipeline | tee results.csv +``` + +--- + +## Project Structure + +``` +include/kpn/ + fixed_string.hpp — NTTP string, in<>/out<> tags, index_of + traits.hpp — function_traits, normalised_return_t, output_count_v + channel.hpp — Channel, channel_storage_policy, exceptions + port.hpp — InputPort, OutputPort + node.hpp — Node,out<...>>, make_node, INode + network.hpp — Network (builder, cycle detection, watchdog) + variant_node.hpp — VariantNode, PythonConverter, unique_types (Python layer) + python/ + bindings.hpp — nanobind helpers, GIL rule documentation + kpn.hpp — umbrella header +src/ + network.cpp — non-template Network implementation +tests/ + test_fixed_string.cpp + test_traits.cpp + test_channel.cpp + test_node.cpp + test_network.cpp +python/ + kpn_python.cpp — nanobind module entry point +examples/ + 01_hello_pipeline/ … 09_opencv_cellshade/ +scripts/ + render_readme.py — regenerates README.md from README.md.in +``` diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt new file mode 100644 index 0000000..97ef6f4 --- /dev/null +++ b/benchmarks/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.21) + +add_executable(bench_pipeline bench_pipeline.cpp) +target_link_libraries(bench_pipeline PRIVATE kpn) +target_compile_options(bench_pipeline PRIVATE -O3 -march=native) + +find_package(TBB QUIET) +if(TBB_FOUND) + target_link_libraries(bench_pipeline PRIVATE TBB::tbb) + target_compile_definitions(bench_pipeline PRIVATE KPN_BENCH_TBB=1) + message(STATUS "TBB found — enabling TBB benchmarks") +else() + message(STATUS "TBB not found — TBB benchmarks disabled") +endif() diff --git a/benchmarks/bench_pipeline.cpp b/benchmarks/bench_pipeline.cpp new file mode 100644 index 0000000..c0e3b09 --- /dev/null +++ b/benchmarks/bench_pipeline.cpp @@ -0,0 +1,526 @@ +// Throughput benchmark: items/second vs. graph topology and size. +// +// Topologies: +// chain — linear depth D: push → n[0..D-1] → pop +// wide — fanout: push → fanout → W parallel nodes → W pops +// diamond — push → fanout<2> → 2×2 nodes → 2 pops +// +// Two scheduling modes for each topology: +// private — each node owns a private ThreadPool(1) [Node<>] +// pool — all nodes share one ThreadPool(T) [PoolNode<> + shared pool] +// +// Usage: ./bench_pipeline | tee results.csv + +#include + +#ifdef KPN_BENCH_TBB +#include +namespace tbb_flow = oneapi::tbb::flow; +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace kpn; +using namespace std::chrono_literals; +using sclock = std::chrono::steady_clock; + +// ── configurable work ───────────────────────────────────────────────────────── + +static std::atomic g_work_us{0}; + +static int chain_fn(int x) { + int us = g_work_us.load(std::memory_order_relaxed); + if (us > 0) { + auto end = sclock::now() + std::chrono::microseconds(us); + while (sclock::now() < end); + } + return x; +} + +using ChainNode = Node, out<>>; +using PoolChainNode = PoolNode, out<>>; + +// ── push helper: yield-spin on overflow (no artificial sleep latency) ───────── + +static void push_retry(Channel& ch, int val) { + while (true) { + try { ch.push(val); return; } + catch (const ChannelOverflowError&) { std::this_thread::yield(); } + catch (const ChannelClosedError&) { return; } + } +} + +// ── result ──────────────────────────────────────────────────────────────────── + +struct Result { + const char* topology; + int size; + int work_us; + int threads; // 0 = private (1 thread per node), N = shared pool size + double items_per_sec; + double overhead_us; +}; + +// ── chain ───────────────────────────────────────────────────────────────────── + +static int items_for(int work_us, int depth = 1) { + int effective = std::max(1, work_us) * std::max(1, depth); + if (effective <= 1) return 5000; + if (effective <= 10) return 3000; + if (effective <= 100) return 1000; + if (effective <= 1000) return 200; + return 50; +} + +static Result bench_chain(int depth, int work_us) { + const int N = items_for(work_us, depth); + const int CAP = N; + + std::vector>> chs; + for (int i = 0; i <= depth; ++i) + chs.push_back(std::make_shared>(CAP)); + + std::vector> nodes; + for (int i = 0; i < depth; ++i) { + nodes.push_back(std::make_unique(CAP)); + nodes.back()->set_input_channel<0>(chs[i]); + nodes.back()->set_output_channel<0>(chs[i + 1].get()); + } + + for (auto& n : nodes) n->start(); + + std::atomic t1; + std::thread reader([&] { + for (int i = 0; i < N; ++i) chs.back()->pop(); + t1.store(sclock::now(), std::memory_order_release); + }); + + auto t0 = sclock::now(); + std::thread pusher([&] { + for (int i = 0; i < N; ++i) push_retry(*chs[0], i); + }); + + pusher.join(); + reader.join(); + for (auto& n : nodes) n->stop(); + + double elapsed = std::chrono::duration( + t1.load(std::memory_order_acquire) - t0).count(); + // Subtract theoretical pipeline fill cost (depth-1)*W so that overhead + // reflects only framework latency, not the expected pipeline startup time. + double pipeline_us = static_cast(work_us) * (N + depth - 1); + double wus = (elapsed * 1e6 - pipeline_us) / N; + return {"chain", depth, work_us, 0, N / elapsed, wus}; +} + +static Result bench_chain_pool(int depth, int work_us, int pool_threads) { + const int N = items_for(work_us, depth); + const int CAP = N; + + auto pool = std::make_shared(pool_threads); + + std::vector>> chs; + for (int i = 0; i <= depth; ++i) + chs.push_back(std::make_shared>(CAP)); + + std::vector> nodes; + for (int i = 0; i < depth; ++i) { + nodes.push_back(std::make_unique(pool, CAP)); + nodes.back()->set_input_channel<0>(chs[i]); + nodes.back()->set_output_channel<0>(chs[i + 1].get()); + } + + pool->start(); + for (auto& n : nodes) n->start(); + + std::atomic t1; + std::thread reader([&] { + for (int i = 0; i < N; ++i) chs.back()->pop(); + t1.store(sclock::now(), std::memory_order_release); + }); + + auto t0 = sclock::now(); + std::thread pusher([&] { + for (int i = 0; i < N; ++i) push_retry(*chs[0], i); + }); + + pusher.join(); + reader.join(); + for (auto& n : nodes) n->stop(); + pool->stop(); + + double elapsed = std::chrono::duration( + t1.load(std::memory_order_acquire) - t0).count(); + double pipeline_us = static_cast(work_us) * (N + depth - 1); + double wus = (elapsed * 1e6 - pipeline_us) / N; + return {"chain", depth, work_us, pool_threads, N / elapsed, wus}; +} + +// ── wide (fanout) ────────────────────────────────────────────────────────── + +template +static Result bench_wide(int work_us) { + const int N = items_for(work_us); + const int CAP = N; + + auto src_ch = std::make_shared>(CAP); + auto fan = std::make_unique>(CAP); + fan->template set_input_channel<0>(src_ch); + + std::array, W> nodes; + std::array>, W> sink_chs; + + for (std::size_t i = 0; i < W; ++i) { + nodes[i] = std::make_unique(CAP); + sink_chs[i] = std::make_shared>(CAP); + nodes[i]->template set_output_channel<0>(sink_chs[i].get()); + } + + [&](std::index_sequence) { + (fan->template set_output_channel( + &nodes[Is]->template input_channel<0>()), ...); + }(std::make_index_sequence{}); + + fan->start(); + for (auto& n : nodes) n->start(); + + std::array readers; + std::atomic t1; + std::atomic readers_done{0}; + + for (std::size_t w = 0; w < W; ++w) { + readers[w] = std::thread([&, w] { + for (int i = 0; i < N; ++i) sink_chs[w]->pop(); + if (readers_done.fetch_add(1, std::memory_order_acq_rel) + 1 + == static_cast(W)) + t1.store(sclock::now(), std::memory_order_release); + }); + } + + auto t0 = sclock::now(); + std::thread pusher([&] { + for (int i = 0; i < N; ++i) push_retry(*src_ch, i); + }); + + pusher.join(); + for (auto& r : readers) r.join(); + fan->stop(); + for (auto& n : nodes) n->stop(); + + double elapsed = std::chrono::duration( + t1.load(std::memory_order_acquire) - t0).count(); + double wus = (elapsed * 1e6) / N - static_cast(work_us); + return {"wide", static_cast(W), work_us, 0, N / elapsed, wus}; +} + +template +static Result bench_wide_pool(int work_us, int pool_threads) { + const int N = items_for(work_us); + const int CAP = N; + + auto pool = std::make_shared(pool_threads); + auto src_ch = std::make_shared>(CAP); + auto fan = std::make_unique>(CAP); + fan->template set_input_channel<0>(src_ch); + + std::array, W> nodes; + std::array>, W> sink_chs; + + for (std::size_t i = 0; i < W; ++i) { + nodes[i] = std::make_unique(pool, CAP); + sink_chs[i] = std::make_shared>(CAP); + nodes[i]->template set_output_channel<0>(sink_chs[i].get()); + } + + [&](std::index_sequence) { + (fan->template set_output_channel( + &nodes[Is]->template input_channel<0>()), ...); + }(std::make_index_sequence{}); + + fan->start(); + pool->start(); + for (auto& n : nodes) n->start(); + + std::array readers; + std::atomic t1; + std::atomic readers_done{0}; + + for (std::size_t w = 0; w < W; ++w) { + readers[w] = std::thread([&, w] { + for (int i = 0; i < N; ++i) sink_chs[w]->pop(); + if (readers_done.fetch_add(1, std::memory_order_acq_rel) + 1 + == static_cast(W)) + t1.store(sclock::now(), std::memory_order_release); + }); + } + + auto t0 = sclock::now(); + std::thread pusher([&] { + for (int i = 0; i < N; ++i) push_retry(*src_ch, i); + }); + + pusher.join(); + for (auto& r : readers) r.join(); + fan->stop(); + for (auto& n : nodes) n->stop(); + pool->stop(); + + double elapsed = std::chrono::duration( + t1.load(std::memory_order_acquire) - t0).count(); + double wus = (elapsed * 1e6) / N - static_cast(work_us); + return {"wide", static_cast(W), work_us, pool_threads, N / elapsed, wus}; +} + +// ── diamond ─────────────────────────────────────────────────────────────────── + +static Result bench_diamond(int work_us) { + const int N = items_for(work_us, 2); + const int CAP = N; + + auto src_ch = std::make_shared>(CAP); + auto fan = std::make_unique>(CAP); + fan->template set_input_channel<0>(src_ch); + + auto nL = std::make_unique(CAP); + auto nR = std::make_unique(CAP); + auto nL2 = std::make_unique(CAP); + auto nR2 = std::make_unique(CAP); + auto chL = std::make_shared>(CAP); + auto chR = std::make_shared>(CAP); + auto snkL = std::make_shared>(CAP); + auto snkR = std::make_shared>(CAP); + + fan->template set_output_channel<0>(&nL->template input_channel<0>()); + fan->template set_output_channel<1>(&nR->template input_channel<0>()); + nL->set_output_channel<0>(chL.get()); + nR->set_output_channel<0>(chR.get()); + nL2->set_input_channel<0>(chL); + nR2->set_input_channel<0>(chR); + nL2->set_output_channel<0>(snkL.get()); + nR2->set_output_channel<0>(snkR.get()); + + fan->start(); nL->start(); nR->start(); nL2->start(); nR2->start(); + + std::atomic t1; + std::atomic done{0}; + auto make_reader = [&](Channel& ch) { + return std::thread([&] { + for (int i = 0; i < N; ++i) ch.pop(); + if (done.fetch_add(1, std::memory_order_acq_rel) + 1 == 2) + t1.store(sclock::now(), std::memory_order_release); + }); + }; + auto rL = make_reader(*snkL); + auto rR = make_reader(*snkR); + + auto t0 = sclock::now(); + std::thread pusher([&] { + for (int i = 0; i < N; ++i) push_retry(*src_ch, i); + }); + + pusher.join(); rL.join(); rR.join(); + fan->stop(); nL->stop(); nR->stop(); nL2->stop(); nR2->stop(); + + double elapsed = std::chrono::duration( + t1.load(std::memory_order_acquire) - t0).count(); + double wus = (elapsed * 1e6) / N - static_cast(work_us); + return {"diamond", 4, work_us, 0, N / elapsed, wus}; +} + +static Result bench_diamond_pool(int work_us, int pool_threads) { + const int N = items_for(work_us, 2); + const int CAP = N; + + auto pool = std::make_shared(pool_threads); + auto src_ch = std::make_shared>(CAP); + auto fan = std::make_unique>(CAP); + fan->template set_input_channel<0>(src_ch); + + auto nL = std::make_unique(pool, CAP); + auto nR = std::make_unique(pool, CAP); + auto nL2 = std::make_unique(pool, CAP); + auto nR2 = std::make_unique(pool, CAP); + auto chL = std::make_shared>(CAP); + auto chR = std::make_shared>(CAP); + auto snkL = std::make_shared>(CAP); + auto snkR = std::make_shared>(CAP); + + fan->template set_output_channel<0>(&nL->template input_channel<0>()); + fan->template set_output_channel<1>(&nR->template input_channel<0>()); + nL->set_output_channel<0>(chL.get()); + nR->set_output_channel<0>(chR.get()); + nL2->set_input_channel<0>(chL); + nR2->set_input_channel<0>(chR); + nL2->set_output_channel<0>(snkL.get()); + nR2->set_output_channel<0>(snkR.get()); + + fan->start(); + pool->start(); + nL->start(); nR->start(); nL2->start(); nR2->start(); + + std::atomic t1; + std::atomic done{0}; + auto make_reader = [&](Channel& ch) { + return std::thread([&] { + for (int i = 0; i < N; ++i) ch.pop(); + if (done.fetch_add(1, std::memory_order_acq_rel) + 1 == 2) + t1.store(sclock::now(), std::memory_order_release); + }); + }; + auto rL = make_reader(*snkL); + auto rR = make_reader(*snkR); + + auto t0 = sclock::now(); + std::thread pusher([&] { + for (int i = 0; i < N; ++i) push_retry(*src_ch, i); + }); + + pusher.join(); rL.join(); rR.join(); + fan->stop(); + nL->stop(); nR->stop(); nL2->stop(); nR2->stop(); + pool->stop(); + + double elapsed = std::chrono::duration( + t1.load(std::memory_order_acquire) - t0).count(); + double wus = (elapsed * 1e6) / N - static_cast(work_us); + return {"diamond", 4, work_us, pool_threads, N / elapsed, wus}; +} + +// ── TBB flow graph ──────────────────────────────────────────────────────────── +#ifdef KPN_BENCH_TBB + +static Result bench_chain_tbb(int depth, int work_us) { + const int N = items_for(work_us, depth); + + tbb_flow::graph g; + using FN = tbb_flow::function_node; + std::vector> nodes; + nodes.reserve(depth); + for (int i = 0; i < depth; ++i) + nodes.push_back(std::make_unique(g, tbb_flow::serial, + [](int x) -> int { return chain_fn(x); })); + for (int i = 0; i + 1 < depth; ++i) + tbb_flow::make_edge(*nodes[i], *nodes[i + 1]); + + auto t0 = sclock::now(); + for (int i = 0; i < N; ++i) nodes[0]->try_put(i); + g.wait_for_all(); + auto t1 = sclock::now(); + + double elapsed = std::chrono::duration(t1 - t0).count(); + double pipeline_us = static_cast(work_us) * (N + depth - 1); + double wus = (elapsed * 1e6 - pipeline_us) / N; + return {"chain_tbb", depth, work_us, -1, N / elapsed, wus}; +} + +template +static Result bench_wide_tbb(int work_us) { + const int N = items_for(work_us); + + tbb_flow::graph g; + tbb_flow::broadcast_node fan(g); + using FN = tbb_flow::function_node; + std::array, W> nodes; + for (auto& n : nodes) { + n = std::make_unique(g, tbb_flow::serial, + [](int x) -> int { return chain_fn(x); }); + tbb_flow::make_edge(fan, *n); + } + + auto t0 = sclock::now(); + for (int i = 0; i < N; ++i) fan.try_put(i); + g.wait_for_all(); + auto t1 = sclock::now(); + + double elapsed = std::chrono::duration(t1 - t0).count(); + double wus = (elapsed * 1e6) / N - static_cast(work_us); + return {"wide_tbb", static_cast(W), work_us, -1, N / elapsed, wus}; +} + +static Result bench_diamond_tbb(int work_us) { + const int N = items_for(work_us, 2); + + tbb_flow::graph g; + tbb_flow::broadcast_node fan(g); + using FN = tbb_flow::function_node; + auto fn = [](int x) -> int { return chain_fn(x); }; + FN nL(g, tbb_flow::serial, fn), nR(g, tbb_flow::serial, fn); + FN nL2(g, tbb_flow::serial, fn), nR2(g, tbb_flow::serial, fn); + tbb_flow::make_edge(fan, nL); tbb_flow::make_edge(fan, nR); + tbb_flow::make_edge(nL, nL2); tbb_flow::make_edge(nR, nR2); + + auto t0 = sclock::now(); + for (int i = 0; i < N; ++i) fan.try_put(i); + g.wait_for_all(); + auto t1 = sclock::now(); + + double elapsed = std::chrono::duration(t1 - t0).count(); + double wus = (elapsed * 1e6) / N - static_cast(work_us); + return {"diamond_tbb", 4, work_us, -1, N / elapsed, wus}; +} +#endif // KPN_BENCH_TBB + +// ── main ────────────────────────────────────────────────────────────────────── + +int main() { + const int work_amts[] = {10, 100, 1000}; + const int pool_sizes[] = {1, 2, 4}; + + std::fprintf(stderr, "%-12s %-8s %-10s %-8s %-18s %-20s\n", + "topology", "size", "work_us", "threads", "items/sec", "overhead_us/item"); + std::fprintf(stderr, "%s\n", std::string(78, '-').c_str()); + std::printf("topology,size,work_us,threads,items_per_sec,overhead_us_per_item\n"); + + auto emit = [](const Result& r) { + std::string sched = r.threads < 0 ? "tbb" + : r.threads == 0 ? "priv" + : std::to_string(r.threads); + std::fprintf(stderr, "%-12s %-8d %-10d %-8s %-18.0f %-20.1f\n", + r.topology, r.size, r.work_us, sched.c_str(), + r.items_per_sec, r.overhead_us); + std::printf("%s,%d,%d,%s,%.0f,%.2f\n", + r.topology, r.size, r.work_us, sched.c_str(), + r.items_per_sec, r.overhead_us); + std::fflush(stdout); + }; + + for (int w : work_amts) { + g_work_us.store(w, std::memory_order_relaxed); + std::fprintf(stderr, "\n── work_us=%-4d private pools ───────────────────────────────────────\n", w); + + for (int d : {1, 2, 4, 8, 16, 32}) emit(bench_chain(d, w)); + emit(bench_wide<1>(w)); + emit(bench_wide<2>(w)); + emit(bench_wide<3>(w)); + emit(bench_wide<4>(w)); + emit(bench_diamond(w)); + + for (int pt : pool_sizes) { + std::fprintf(stderr, "\n── work_us=%-4d shared pool (%d thread%s) ─────────────────────────────\n", + w, pt, pt == 1 ? "" : "s"); + for (int d : {1, 2, 4, 8, 16, 32}) emit(bench_chain_pool(d, w, pt)); + emit(bench_wide_pool<1>(w, pt)); + emit(bench_wide_pool<2>(w, pt)); + emit(bench_wide_pool<3>(w, pt)); + emit(bench_wide_pool<4>(w, pt)); + emit(bench_diamond_pool(w, pt)); + } + +#ifdef KPN_BENCH_TBB + std::fprintf(stderr, "\n── work_us=%-4d TBB flow graph ──────────────────────────────────────\n", w); + for (int d : {1, 2, 4, 8, 16, 32}) emit(bench_chain_tbb(d, w)); + emit(bench_wide_tbb<1>(w)); + emit(bench_wide_tbb<2>(w)); + emit(bench_wide_tbb<3>(w)); + emit(bench_wide_tbb<4>(w)); + emit(bench_diamond_tbb(w)); +#endif + } +} diff --git a/examples/01_hello_pipeline/main.cpp b/examples/01_hello_pipeline/main.cpp index d79972e..a86580c 100644 --- a/examples/01_hello_pipeline/main.cpp +++ b/examples/01_hello_pipeline/main.cpp @@ -7,16 +7,20 @@ // // [produce] --int--> [double_it] --int--> [print_it] +// [snippet: basic_node_fns] static int produce() { return 42; } static int double_it(int x) { return x * 2; } static void print_it(int x) { std::cout << "result: " << x << '\n'; } +// [/snippet: basic_node_fns] int main() { using namespace kpn; + // [snippet: index_only_nodes] auto src = make_node(5); auto dbl = make_node(5); auto sink = make_node(5); + // [/snippet: index_only_nodes] // Wire channels auto& dbl_in = dbl.input_channel<0>(); @@ -24,6 +28,7 @@ int main() { src.set_output_channel<0>(&dbl_in); dbl.set_output_channel<0>(&sink_in); + // [snippet: network_build] Network net; net.add("src", src) .add("dbl", dbl) @@ -35,4 +40,5 @@ int main() { net.start(); std::this_thread::sleep_for(std::chrono::milliseconds(100)); net.stop(); + // [/snippet: network_build] } diff --git a/examples/02_named_ports/main.cpp b/examples/02_named_ports/main.cpp index 1c09212..752e316 100644 --- a/examples/02_named_ports/main.cpp +++ b/examples/02_named_ports/main.cpp @@ -54,17 +54,18 @@ static void report(int count, std::vector words) { int main() { using namespace kpn; + // [snippet: named_port_creation] // tokenise: no inputs, one named output "words" auto tok = make_node(out<"words">{}, 4); // count_words: named input "words", named outputs "count" and "words" auto cnt = make_node(in<"words">{}, out<"count", "words">{}, 4); - // report: two named inputs — note the function takes (int, vector) - // so we need two separate input ports wired independently - // For a two-input sink we wire each output of cnt to a different input of report + // report: two named inputs auto snk = make_node(in<"count", "words">{}, 4); + // [/snippet: named_port_creation] + // [snippet: named_port_network] Network net; net.add("tok", tok) .add("cnt", cnt) @@ -77,4 +78,5 @@ int main() { net.start(); std::this_thread::sleep_for(std::chrono::milliseconds(500)); net.stop(); + // [/snippet: named_port_network] } diff --git a/examples/03_multi_output/main.cpp b/examples/03_multi_output/main.cpp index a648770..a783064 100644 --- a/examples/03_multi_output/main.cpp +++ b/examples/03_multi_output/main.cpp @@ -33,6 +33,7 @@ static std::string generate() { return pairs[gen_index++ % 5]; } +// [snippet: multi_output_fn] // Multi-output: returns (key, value) as a tuple — KPN++ routes each element // to its own output port automatically. static std::tuple parse(std::string kv) { @@ -40,6 +41,7 @@ static std::tuple parse(std::string kv) { if (sep == std::string::npos) return {kv, ""}; return {kv.substr(0, sep), kv.substr(sep + 1)}; } +// [/snippet: multi_output_fn] static void print_key(std::string key) { std::cout << "KEY → " << key << '\n'; @@ -54,6 +56,7 @@ static void print_value(std::string value) { int main() { using namespace kpn; + // [snippet: fanout_network] auto gen = make_node(out<"kv">{}, 4); auto par = make_node (in<"kv">{}, out<"key", "value">{}, 4); auto keys = make_node (in<"key">{}, 4); @@ -72,4 +75,5 @@ int main() { net.start(); std::this_thread::sleep_for(std::chrono::milliseconds(600)); net.stop(); + // [/snippet: fanout_network] } diff --git a/examples/04_storage_policy/main.cpp b/examples/04_storage_policy/main.cpp index 30ef08d..c46b73d 100644 --- a/examples/04_storage_policy/main.cpp +++ b/examples/04_storage_policy/main.cpp @@ -34,12 +34,14 @@ struct Tag { int value = 0; }; +// [snippet: storage_policy_spec] // Override: store Tag by value despite being a struct // (it's trivially copyable and small — this just makes the policy explicit) template<> struct kpn::channel_storage_policy { static constexpr bool by_value = true; }; +// [/snippet: storage_policy_spec] // ── Node functions ──────────────────────────────────────────────────────────── diff --git a/examples/05_error_handling/main.cpp b/examples/05_error_handling/main.cpp index 4de1ebb..395606b 100644 --- a/examples/05_error_handling/main.cpp +++ b/examples/05_error_handling/main.cpp @@ -45,6 +45,7 @@ int main() { Network net; + // [snippet: diagnostics_handler] // Custom diagnostics handler — fires on the watchdog interval. // Print a concise one-liner rather than the full table. net.set_diagnostics_handler([](const std::vector& nodes, @@ -57,6 +58,7 @@ int main() { << "overflows=" << c.overflows; std::cout << '\n'; }); + // [/snippet: diagnostics_handler] net.set_watchdog_interval(std::chrono::milliseconds(200)); diff --git a/examples/09_opencv_cellshade/example_hybrid.py b/examples/09_opencv_cellshade/example_hybrid.py new file mode 100644 index 0000000..0c4df3d --- /dev/null +++ b/examples/09_opencv_cellshade/example_hybrid.py @@ -0,0 +1,101 @@ +""" +09_opencv_cellshade/example_hybrid.py +────────────────────────────────────── +Hybrid cell-shading pipeline: C++ nodes handle capture, grayscale conversion, +and edge detection; a Python/numpy function replaces the C++ quantise node; +Python drives the display loop using cv2. + +Pipeline: + ┌─[py_quantise]──────────────┐ + [CaptureNode] ─────┤ ├──[CompositeNode]──result──▶ cv2.imshow + out0=colour └─[ToGrayNode]─[EdgesNode]───┘ edges───▶ cv2.imshow + out1=grey + +For a pure-C++ version see main.cpp; for the C++ static-network version see +12_static_cellshade/main.cpp. + +Press 'q' or Esc to stop. +""" + +import sys +import os + +# Adjust path to wherever CMake placed the .so +BUILD_DIR = os.environ.get("KPN_BUILD_DIR", + os.path.join(os.path.dirname(__file__), + "../../build/examples")) +sys.path.insert(0, BUILD_DIR) + +import numpy as np +import cv2 +import kpn_opencv as kpn + + +# ── Python node: replace the C++ quantise with numpy ───────────────────────── +# Receives and returns a BGR numpy array (H×W×3 uint8). + +def py_quantise(bgr: np.ndarray) -> np.ndarray: + levels = 4 + step = 256 // levels + q = (bgr.astype(np.int32) // step) * step + (step // 2) + return q.clip(0, 255).astype(np.uint8) + + +# ── Build network ───────────────────────────────────────────────────────────── + +net = kpn.Network() + +net.add("src", kpn.make_capture()) # out0=colour, out1=grey +net.add_node("quant", py_quantise, # Python node — numpy in/out + inputs=["mat"], outputs=["mat"]) +net.add("gray", kpn.make_to_gray()) # in0=bgr → out0=gray +net.add("edges", kpn.make_edges()) # in0=gray → out0=edge_mask +net.add("comp", kpn.make_composite()) # in0=edge_mask, in1=colour + # out0=result, out1=edge_mask + +# src.colour → py_quantise +net.connect("src", 0, "quant", 0) +# src.grey → to_gray +net.connect("src", 1, "gray", 0) +# gray → edges +net.connect("gray", 0, "edges", 0) +# quantised colour → composite.colour (input slot 1) +net.connect("quant", 0, "comp", 1) +# edge mask → composite.edges (input slot 0) +net.connect("edges", 0, "comp", 0) + +net.build() +net.start() + +# ── Display loop (drives GUI on this thread) ────────────────────────────────── + +cv2.namedWindow("Cell Shade (Python quant)", cv2.WINDOW_NORMAL) +cv2.namedWindow("Edge Mask", cv2.WINDOW_NORMAL) +cv2.resizeWindow("Cell Shade (Python quant)", 1280, 720) +cv2.resizeWindow("Edge Mask", 640, 360) + +try: + while True: + # Blocking reads — GIL released while waiting so C++ threads can run + result = net.read("comp", 0) # composite frame (BGR numpy array) + edges = net.read("comp", 1) # edge mask (grayscale numpy array) + + cv2.imshow("Cell Shade (Python quant)", result) + cv2.imshow("Edge Mask", cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)) + + key = cv2.waitKey(1) + if key in (ord('q'), 27): + break + + # Check windows still open + try: + if cv2.getWindowProperty("Cell Shade (Python quant)", + cv2.WND_PROP_VISIBLE) < 1: + break + except cv2.error: + break + +finally: + net.stop() + cv2.destroyAllWindows() + del net # let C++ destructor run before nanobind tears down diff --git a/examples/09_opencv_cellshade/kpn_opencv.cpp b/examples/09_opencv_cellshade/kpn_opencv.cpp new file mode 100644 index 0000000..e59ea8c --- /dev/null +++ b/examples/09_opencv_cellshade/kpn_opencv.cpp @@ -0,0 +1,177 @@ +#define KPN_BUILD_PYTHON +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace nb = nanobind; +using namespace kpn; +using namespace kpn::python; + +// ── PythonConverter ────────────────────────────────────────────────── +// Converts cv::Mat ↔ numpy array (uint8, HxW or HxWxC shape). +// +// to_python: clones the mat onto the heap; the numpy array owns it via a +// capsule deleter — no shared cv::Mat refcount dangling after the Variant dies. +// from_python: calls numpy.ascontiguousarray, then clones into an owned cv::Mat. + +namespace kpn { + +template<> struct PythonConverter { + static constexpr const char* type_name = "mat"; + + static nb::object to_python(const cv::Mat& m) { + // Must be called with the GIL held (always true: called from read() or + // from within the gil_scoped_acquire block in PyNode::run_loop). + auto np = nb::module_::import_("numpy"); + cv::Mat c = m.clone(); // ensure contiguous, independently owned + nb::bytes raw(reinterpret_cast(c.data), + c.total() * c.elemSize()); + nb::object arr = np.attr("frombuffer")(raw, "uint8"); + int H = c.rows, W = c.cols, C = c.channels(); + arr = arr.attr("reshape")( + C > 1 ? nb::make_tuple(H, W, C) : nb::make_tuple(H, W)); + return arr.attr("copy")(); // writable, lifetime-independent copy + } + + static cv::Mat from_python(nb::object o) { + auto np = nb::module_::import_("numpy"); + // Ensure contiguous uint8 layout (in-place if already compatible) + nb::object arr = np.attr("ascontiguousarray")(o, "uint8"); + auto shape = nb::cast>(arr.attr("shape")); + if (shape.size() < 2 || shape.size() > 3) + throw std::runtime_error( + "cv::Mat from_python: expected 2D (H×W) or 3D (H×W×C) uint8 array"); + int H = shape[0], W = shape[1]; + int C = (shape.size() == 3) ? shape[2] : 1; + int type = C > 1 ? CV_8UC(C) : CV_8UC1; + + // Cast to ndarray to get the raw data pointer + auto binfo = nb::cast>(arr); + cv::Mat wrap(H, W, type, binfo.data()); + return wrap.clone(); // own the pixel data + } +}; + +} // namespace kpn + +// ── Pipeline functions ──────────────────────────────────────────────────────── + +static cv::Mat make_gradient(int W, int H) { + cv::Mat xr(H, W, CV_8UC1), yg(H, W, CV_8UC1), b(H, W, CV_8UC1, cv::Scalar(128)); + for (int x = 0; x < W; ++x) xr.col(x).setTo(x * 255 / W); + for (int y = 0; y < H; ++y) yg.row(y).setTo(y * 255 / H); + cv::Mat channels[3] = {b, yg, xr}; + cv::Mat grad; + cv::merge(channels, 3, grad); + return grad; +} + +static std::tuple capture() { + constexpr int W = 640, H = 480; + static cv::VideoCapture cap; + static bool opened = false; + if (!opened) { + opened = true; + cap.open(0, cv::CAP_V4L2); + if (cap.isOpened()) { + cap.set(cv::CAP_PROP_FRAME_WIDTH, W); + cap.set(cv::CAP_PROP_FRAME_HEIGHT, H); + } else { + std::cerr << "[capture] no webcam — using synthetic animated pattern\n"; + } + } + + cv::Mat frame; + if (cap.isOpened()) { + auto t0 = std::chrono::steady_clock::now(); + cap >> frame; + auto elapsed = std::chrono::steady_clock::now() - t0; + if (elapsed < std::chrono::milliseconds(20)) + std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed); + if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3); + } else { + static int tick = 0; + static cv::Mat grad = make_gradient(W, H); + ++tick; + frame = grad.clone(); + int r = 150 + (tick % 80) * 4; + cv::circle(frame, {W/2, H/2}, r, {255, 200, 0}, -1); + cv::circle(frame, {W/2, H/2}, r / 2, { 0, 128, 255}, -1); + cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200, 0, 200}, -1); + std::this_thread::sleep_for(std::chrono::milliseconds(33)); + } + return {frame.clone(), frame.clone()}; +} + +static cv::Mat to_gray(cv::Mat bgr) { + cv::Mat gray; + cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY); + return gray; +} + +static cv::Mat edges_fn(cv::Mat gray) { + cv::Mat blurred, mask; + cv::GaussianBlur(gray, blurred, {5, 5}, 0); + cv::Canny(blurred, mask, 50, 150); + return mask; +} + +static cv::Mat quantise(cv::Mat bgr) { + constexpr int levels = 4; + constexpr double step = 256.0 / levels; + static const cv::Mat lut = []() { + cv::Mat l(1, 256, CV_8UC1); + for (int i = 0; i < 256; ++i) + l.at(i) = cv::saturate_cast( + std::floor(i / step) * step + step / 2.0); + return l; + }(); + cv::Mat out; + cv::LUT(bgr, lut, out); + return out; +} + +// Returns composite frame AND edge mask so the display node can show both +// without needing a fan-out on the edges channel. +static std::tuple composite(cv::Mat edge_mask, cv::Mat colour) { + cv::Mat result = colour.clone(); + result.setTo(cv::Scalar(0, 0, 0), edge_mask); + return {result, edge_mask}; +} + +// ── Registry ────────────────────────────────────────────────────────────────── +// Variant deduced as std::variant — every node uses only cv::Mat. + +using CvNodes = NodeRegistry< + Entry, + Entry, + Entry, + Entry, + Entry +>; + +// ── Module ──────────────────────────────────────────────────────────────────── + +NB_MODULE(kpn_opencv, m) { + m.doc() = "KPN++ OpenCV bindings for the cell-shading pipeline"; + + // Registers: Network, INode, CaptureNode, ToGrayNode, EdgesNode, + // QuantiseNode, CompositeNode, and make_() factories. + // Network.add_node(name, callable, inputs=["mat"], outputs=["mat"]) + // accepts Python callables that receive/return numpy uint8 arrays. + bind_network(m); + + // Note: bind_debug is omitted here — cv::Mat functions cannot be called + // directly from Python without the variant/network machinery. Use + // net.write() + net.read() to inject/inspect individual nodes instead. +} diff --git a/examples/09_opencv_cellshade/main.cpp b/examples/09_opencv_cellshade/main.cpp index 5884f5b..c715107 100644 --- a/examples/09_opencv_cellshade/main.cpp +++ b/examples/09_opencv_cellshade/main.cpp @@ -8,6 +8,12 @@ #include #include +// Teach KPN how many bytes a cv::Mat actually carries (header + pixel data). +template<> +struct kpn::ChannelDataSize { + static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); } +}; + // ── Cell-shading pipeline ───────────────────────────────────────────────────── // // [capture] --"colour"--> [quantise] ──────────────────────────┐ @@ -32,6 +38,7 @@ static cv::Mat make_gradient(int W, int H) { // ── Pipeline functions ──────────────────────────────────────────────────────── +// [snippet: capture_fn] static std::tuple capture() { constexpr int W = 640, H = 480; static cv::VideoCapture cap; @@ -68,6 +75,7 @@ static std::tuple capture() { } return {frame.clone(), frame.clone()}; } +// [/snippet: capture_fn] static cv::Mat to_gray(cv::Mat bgr) { cv::Mat gray; @@ -112,6 +120,7 @@ static std::tuple composite(cv::Mat edge_mask, cv::Mat colour) // The constructor opens both windows on the main thread (Wayland requirement). // operator() is called by step() whenever both channels have a frame ready. +// [snippet: display_node] class DisplayNode : public kpn::MainThreadNode, cv::Mat, cv::Mat> { @@ -141,12 +150,14 @@ private: catch (const cv::Exception&) { return false; } } }; +// [/snippet: display_node] // ───────────────────────────────────────────────────────────────────────────── int main() { using namespace kpn; + // [snippet: opencv_network] auto src = make_node (out<"colour","grey">{}, 8); auto gray_node = make_node (in<"bgr">{}, out<"gray">{}, 8); auto edge_node = make_node (in<"gray">{}, out<"edges">{}, 8); @@ -171,13 +182,17 @@ int main() { .connect("comp", comp.template output<"result">(), "display", disp.template input<"composite">()) .connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">()) .build(); + // [/snippet: opencv_network] net.set_watchdog_interval(std::chrono::milliseconds(5000)); +#ifdef KPN_WEB_DEBUG net.set_web_debug_port(9090); +#endif std::cout << "Cell-shading pipeline running. Press 'q' to stop.\n"; std::cout << "Web debug UI: http://localhost:9090\n"; + // [snippet: main_thread_step] net.start(); // Main thread drives display — imshow/waitKey stay on the GUI thread. @@ -186,5 +201,6 @@ int main() { cv::waitKey(8); // yield event loop when no frame ready net.stop(); + // [/snippet: main_thread_step] return 0; } diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 48e5c63..0e8ad1a 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -21,14 +21,18 @@ if(KPN_WEB_DEBUG) target_link_libraries(14_debug_hub PRIVATE kpn) kpn_target_enable_web_debug(14_debug_hub) endif() -# 07 and 08 require the Python bindings — only add if built -if(KPN_BUILD_PYTHON) - # These are Python scripts, not compiled targets — installed alongside kpn_python -endif() +# 07 and 08 are Python scripts — no compiled target needed. # 09 requires OpenCV — only build if found find_package(OpenCV QUIET COMPONENTS core imgproc highgui videoio) if(OpenCV_FOUND) + # Hybrid Python example: kpn_opencv module (requires both OpenCV and nanobind) + if(KPN_BUILD_PYTHON) + nanobind_add_module(kpn_opencv 09_opencv_cellshade/kpn_opencv.cpp) + target_link_libraries(kpn_opencv PRIVATE kpn ${OpenCV_LIBS}) + target_compile_definitions(kpn_opencv PRIVATE KPN_BUILD_PYTHON) + message(STATUS "KPN++ kpn_opencv Python module: building (OpenCV ${OpenCV_VERSION})") + endif() add_executable(09_opencv_cellshade 09_opencv_cellshade/main.cpp) target_link_libraries(09_opencv_cellshade PRIVATE kpn ${OpenCV_LIBS}) diff --git a/include/kpn/branch.hpp b/include/kpn/branch.hpp new file mode 100644 index 0000000..fcd53ce --- /dev/null +++ b/include/kpn/branch.hpp @@ -0,0 +1,301 @@ +#pragma once +#include "channel.hpp" +#include "diagnostics.hpp" +#include "inode.hpp" +#include "port.hpp" +#include "traits.hpp" + +#include +#include +#include +#include +#include + +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( +// [](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 +class RouterNode : public INode { +public: + using Selector = std::function; + using args_tuple = std::tuple; + using return_tuple = repeat_tuple_t; + 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>(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 + InputPort input() { + static_assert(I == 0, "RouterNode has exactly one input"); + return {*this}; + } + + template + OutputPort output() { + static_assert(I < N, "RouterNode output index out of range"); + return {*this}; + } + + // ── Internal channel accessors (called by Network::connect) ─────────────── + + template + Channel& input_channel() { + static_assert(I == 0); + return *input_ch_; + } + + template + void set_input_channel(std::shared_ptr> ch) { + static_assert(I == 0); + input_ch_ = std::move(ch); + } + + template + void set_output_channel(Channel* 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> input_ch_; + std::array*, N> out_channels_{}; + std::atomic 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([](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 +class FilterNode : public INode { +public: + using Predicate = std::function; + using args_tuple = std::tuple; + using return_tuple = std::tuple; + 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>(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 + InputPort input() { + static_assert(I == 0, "FilterNode has exactly one input"); + return {*this}; + } + + template + OutputPort output() { + static_assert(I == 0, "FilterNode has exactly one output"); + return {*this}; + } + + // ── Internal channel accessors (called by Network::connect) ─────────────── + + template + Channel& input_channel() { + static_assert(I == 0); + return *input_ch_; + } + + template + void set_input_channel(std::shared_ptr> ch) { + static_assert(I == 0); + input_ch_ = std::move(ch); + } + + template + void set_output_channel(Channel* 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> input_ch_; + Channel* out_ch_{nullptr}; + std::atomic stop_flag_{false}; + std::jthread thread_; + NodeStats stats_; +}; + +// ── Factories ───────────────────────────────────────────────────────────────── + +template +RouterNode make_router(std::function sel, + std::size_t capacity = 5) { + return RouterNode(std::move(sel), capacity); +} + +template +FilterNode make_filter(std::function pred, + std::size_t capacity = 5) { + return FilterNode(std::move(pred), capacity); +} + +} // namespace kpn diff --git a/include/kpn/channel.hpp b/include/kpn/channel.hpp index 683cb04..3f0b05d 100644 --- a/include/kpn/channel.hpp +++ b/include/kpn/channel.hpp @@ -2,16 +2,30 @@ #include "diagnostics.hpp" #include #include -#include +#include +#include #include -#include -#include #include #include +#include #include 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 { +// static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); } +// }; + +template +struct ChannelDataSize { + static std::size_t bytes(const T&) { return sizeof(T); } +}; + // ── Storage policy ──────────────────────────────────────────────────────────── template @@ -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 class Channel { public: using storage_type = channel_storage_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(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::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 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 queue_; - std::atomic 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 buf_; + std::function 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 head_{0}; + alignas(64) std::atomic tail_{0}; + std::atomic wake_{0}; + std::atomic accepting_{true}; }; // ── Channel probe — type-erased snapshot accessor ───────────────────────────── diff --git a/include/kpn/diagnostics.hpp b/include/kpn/diagnostics.hpp index 4fec2a0..f5080b1 100644 --- a/include/kpn/diagnostics.hpp +++ b/include/kpn/diagnostics.hpp @@ -15,6 +15,7 @@ using duration_t = std::chrono::duration; // milliseconds struct ChannelStats { std::atomic pushes{0}; + std::atomic bytes_pushed{0}; std::atomic drops{0}; std::atomic overflows{0}; std::atomic 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 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 queue_wait_us{0}; // cumulative µs spent in pool queue + std::atomic 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(ts.tv_nsec) / 1'000; } + void record_queue_wait(duration_t wait) { + int64_t us = static_cast(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 uint64_t drops; uint64_t overflows; uint64_t pops; - std::size_t item_bytes; // sizeof(T) for the stored type — set by Channel + 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(pushes * item_bytes) / elapsed_s / 1e6; + if (elapsed_s <= 0.0) return 0.0; + return static_cast(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) ───────────────────────────────── diff --git a/include/kpn/fanout.hpp b/include/kpn/fanout.hpp index 7fb1dc0..180b602 100644 --- a/include/kpn/fanout.hpp +++ b/include/kpn/fanout.hpp @@ -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 with N repetitions — used so that -// Network::connect can do its normal return_tuple type-check against FanoutNode. -template> -struct repeat_tuple; - -template -struct repeat_tuple> { - template using always_T = T; - using type = std::tuple...>; -}; - -template -using repeat_tuple_t = typename repeat_tuple::type; - -} // namespace detail - // ── FanoutNode ──────────────────────────────────────────────────────────────── // // Reads one item from its single input channel and pushes a copy to each of @@ -48,7 +30,7 @@ template class FanoutNode : public INode { public: using args_tuple = std::tuple; - using return_tuple = detail::repeat_tuple_t; + using return_tuple = repeat_tuple_t; using return_raw = return_tuple; static constexpr std::size_t input_count = 1; diff --git a/include/kpn/inode.hpp b/include/kpn/inode.hpp new file mode 100644 index 0000000..1b346bb --- /dev/null +++ b/include/kpn/inode.hpp @@ -0,0 +1,32 @@ +#pragma once +#include "diagnostics.hpp" +#include +#include +#include + +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; + +// ── 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 diff --git a/include/kpn/interrupt_node.hpp b/include/kpn/interrupt_node.hpp new file mode 100644 index 0000000..8838be9 --- /dev/null +++ b/include/kpn/interrupt_node.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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, + fixed_string Label = "", + std::size_t UniqueTag = 0> +class InterruptNode; + +template +class InterruptNode, Label, UniqueTag> : public INode { +public: + using F = decltype(Func); + using return_raw = return_t; + using return_tuple = normalised_return_t; + + static_assert(arity_v == 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; + + 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 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 + OutputPort output() { + static_assert(I < output_count, "output index out of range"); + return {*this}; + } + + template + auto output() { + constexpr std::size_t idx = index_of(); + static_assert(idx != npos, "unknown output port name"); + return output(); + } + + template + void set_output_channel(Channel>* ch) { + std::get(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 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( + 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) { + Func(); + } else { + auto result = Func(); + push_outputs(normalise(std::move(result)), + std::make_index_sequence{}); + } + + 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 + static return_tuple normalise(R&& r) { + if constexpr (is_tuple_v) return std::move(r); + else return std::make_tuple(std::move(r)); + } + + template + void push_outputs(return_tuple&& result, std::index_sequence) { + (push_one(std::get(std::move(result))), ...); + } + + template + void push_one(std::tuple_element_t&& val) { + auto* ch = std::get(output_channels_); + if (!ch) return; + try { ch->push(std::move(val)); } + catch (const ChannelOverflowError&) { + throw ChannelOverflowError(ch->capacity(), + "interrupt node '" + name_ + "' " + output_port_label()); + } + } + + template + static std::string output_port_label() { + if constexpr (sizeof...(OutNames) > 0) { + constexpr std::array names{OutNames.view()...}; + return std::string("output['") + std::string(names[I]) + "']"; + } else { + return "output[" + std::to_string(I) + "]"; + } + } + + template + static auto make_output_channel_tuple(std::index_sequence) + -> std::tuple>*...>; + + using output_channels_t = decltype(make_output_channel_tuple( + std::make_index_sequence{})); + + std::shared_ptr scheduler_; + std::string name_; + std::size_t fifo_capacity_; + output_channels_t output_channels_{}; + std::atomic stop_flag_{true}; + std::atomic pending_{0}; // triggers awaiting execution + NodeStats stats_; + NodeErrorHandler error_handler_; + std::chrono::milliseconds max_exec_time_{0}; +}; + +// ── make_interrupt_node factory ─────────────────────────────────────────────── + +template +auto make_interrupt_node(std::shared_ptr sched, std::size_t fifo_capacity = 5) { + return InterruptNode, Label, UniqueTag>(std::move(sched), fifo_capacity); +} + +template +auto make_interrupt_node(std::shared_ptr sched, out, + std::size_t fifo_capacity = 5) { + return InterruptNode, Label, UniqueTag>( + std::move(sched), fifo_capacity); +} + +} // namespace kpn diff --git a/include/kpn/kpn.hpp b/include/kpn/kpn.hpp index 0c8cc10..1f798ca 100644 --- a/include/kpn/kpn.hpp +++ b/include/kpn/kpn.hpp @@ -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" diff --git a/include/kpn/main_thread_node.hpp b/include/kpn/main_thread_node.hpp index a5c328e..47ecb18 100644 --- a/include/kpn/main_thread_node.hpp +++ b/include/kpn/main_thread_node.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 diff --git a/include/kpn/network.hpp b/include/kpn/network.hpp index 3cb0bfe..3c9b745 100644 --- a/include/kpn/network.hpp +++ b/include/kpn/network.hpp @@ -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 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 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 nodes; std::vector channels; + std::vector 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 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& nodes, const std::vector& channels, + const std::vector& 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& 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& 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( + 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 exposed_outputs_; std::set> connected_outputs_; std::vector> channel_probes_; + std::vector> pool_probes_; ErrorHandler error_handler_; DiagnosticsHandler diag_handler_; std::chrono::milliseconds watchdog_interval_{3000}; diff --git a/include/kpn/node.hpp b/include/kpn/node.hpp index 6ad1567..e3b747d 100644 --- a/include/kpn/node.hpp +++ b/include/kpn/node.hpp @@ -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 -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +// 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; +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 pool{std::make_shared(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, @@ -49,267 +32,25 @@ template class Node; -// Specialisation that unpacks the in<>/out<> tag packs template -class Node, out, Label, UniqueTag> : public INode { +class Node, out, Label, UniqueTag> + : private detail::NodePrivatePool + , public PoolNode, out, Label, UniqueTag> { + using Base = PoolNode, out, Label, UniqueTag>; public: - using F = decltype(Func); - using args_tuple = args_t; - using return_raw = return_t; - using return_tuple = normalised_return_t; - - // 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; - static constexpr std::size_t output_count = std::tuple_size_v; - - 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{}); - } + : detail::NodePrivatePool{} + , Base(pool, fifo_capacity) + {} ~Node() override { stop(); } - // ── INode ───────────────────────────────────────────────────────────────── - - void start() override { - enable_inputs(std::make_index_sequence{}); - 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{}); - 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 - InputPort input() { - static_assert(I < input_count, "input index out of range"); - return {*this}; - } - - template - OutputPort output() { - static_assert(I < output_count, "output index out of range"); - return {*this}; - } - - // ── Port access — by name ───────────────────────────────────────────────── - - template - auto input() { - constexpr std::size_t idx = index_of(); - static_assert(idx != npos, "unknown input port name"); - return input(); - } - - template - auto output() { - constexpr std::size_t idx = index_of(); - static_assert(idx != npos, "unknown output port name"); - return output(); - } - - // ── Internal channel accessors (used by Network at connect time) ────────── - - template - Channel>& input_channel() { - return *std::get(input_channels_); - } - - // Replace the owned input channel with an externally provided one. - // Used by VariantNodeWrapper to share a Channel with a VariantChannel adapter. - template - void set_input_channel( - std::shared_ptr>> ch) { - std::get(input_channels_) = std::move(ch); - } - - template - void set_output_channel( - Channel>* ch) { - std::get(output_channels_) = ch; - } - -private: - // ── Channel storage ─────────────────────────────────────────────────────── - - // Input channels — shared ownership so VariantChannel adapters can share them - template - void init_input_channels(std::index_sequence) { - ((std::get(input_channels_) = - std::make_shared>>(fifo_capacity_)), - ...); - } - - template - void enable_inputs(std::index_sequence) { - (std::get(input_channels_)->enable(), ...); - } - - template - void disable_inputs(std::index_sequence) { - (std::get(input_channels_)->disable(), ...); - } - - template - static auto make_input_channel_tuple(std::index_sequence) - -> std::tuple>>...>; - - using input_channels_t = decltype(make_input_channel_tuple( - std::make_index_sequence{})); - - // Output channels — non-owning pointers, set at connect time - template - static auto make_output_channel_tuple(std::index_sequence) - -> std::tuple>*...>; - - using output_channels_t = decltype(make_output_channel_tuple( - std::make_index_sequence{})); - - // ── 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{}); - auto t1 = clock_t::now(); - auto cpu0 = NodeStats::cpu_now(); - - if constexpr (std::is_void_v) { - std::apply(Func, args); - } else { - auto result = std::apply(Func, args); - push_outputs(normalise(std::move(result)), - std::make_index_sequence{}); - } - - 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 - args_tuple pop_inputs(std::index_sequence) { - return {std::get(input_channels_)->pop()...}; - } - - // Normalise return value to tuple (handles void and single-value returns) - template - static return_tuple normalise(R&& r) { - if constexpr (is_tuple_v) - 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 - void push_outputs(return_tuple&& result, std::index_sequence) { - (push_one(std::get(std::move(result))), ...); - } - - template - void push_one(std::tuple_element_t&& val) { - auto* ch = std::get(output_channels_); - if (!ch) return; - try { - ch->push(std::move(val)); - } catch (const ChannelOverflowError&) { - throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label()); - } - } - - template - static std::string output_port_label() { - if constexpr (sizeof...(OutNames) > 0) { - constexpr std::array 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 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, @@ -320,230 +61,20 @@ class ObjectNode; template -class ObjectNode, out, Label, UniqueTag> : public INode { +class ObjectNode, out, Label, UniqueTag> + : private detail::NodePrivatePool + , public PoolObjectNode, out, Label, UniqueTag> { + using Base = PoolObjectNode, out, Label, UniqueTag>; public: - using F = decltype(&Obj::operator()); - using args_tuple = args_t; - using return_raw = return_t; - using return_tuple = normalised_return_t; - - 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; - static constexpr std::size_t output_count = std::tuple_size_v; - - 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{}); - } + : detail::NodePrivatePool{} + , Base(obj, pool, fifo_capacity) + {} ~ObjectNode() override { stop(); } - // ── INode ───────────────────────────────────────────────────────────────── - - void start() override { - enable_inputs(std::make_index_sequence{}); - 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{}); - 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 - InputPort input() { - static_assert(I < input_count, "input index out of range"); - return {*this}; - } - - template - OutputPort output() { - static_assert(I < output_count, "output index out of range"); - return {*this}; - } - - template - auto input() { - constexpr std::size_t idx = index_of(); - static_assert(idx != npos, "unknown input port name"); - return input(); - } - - template - auto output() { - constexpr std::size_t idx = index_of(); - static_assert(idx != npos, "unknown output port name"); - return output(); - } - - template - Channel>& input_channel() { - return *std::get(input_channels_); - } - - template - void set_input_channel( - std::shared_ptr>> ch) { - std::get(input_channels_) = std::move(ch); - } - - template - void set_output_channel(Channel>* ch) { - std::get(output_channels_) = ch; - } - -private: - template - void init_input_channels(std::index_sequence) { - ((std::get(input_channels_) = - std::make_shared>>(fifo_capacity_)), - ...); - } - - template - void enable_inputs(std::index_sequence) { - (std::get(input_channels_)->enable(), ...); - } - - template - void disable_inputs(std::index_sequence) { - (std::get(input_channels_)->disable(), ...); - } - - template - static auto make_input_channel_tuple(std::index_sequence) - -> std::tuple>>...>; - - using input_channels_t = decltype(make_input_channel_tuple( - std::make_index_sequence{})); - - template - static auto make_output_channel_tuple(std::index_sequence) - -> std::tuple>*...>; - - using output_channels_t = decltype(make_output_channel_tuple( - std::make_index_sequence{})); - - 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{}); - auto t1 = clock_t::now(); - auto cpu0 = NodeStats::cpu_now(); - - if constexpr (std::is_void_v) { - std::apply([this](auto&&... a) { obj_(std::forward(a)...); }, args); - } else { - auto result = std::apply([this](auto&&... a) { return obj_(std::forward(a)...); }, args); - push_outputs(normalise(std::move(result)), - std::make_index_sequence{}); - } - - 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 - args_tuple pop_inputs(std::index_sequence) { - return {std::get(input_channels_)->pop()...}; - } - - template - static return_tuple normalise(R&& r) { - if constexpr (is_tuple_v) return std::move(r); - else return std::make_tuple(std::move(r)); - } - - template - void push_outputs(return_tuple&& result, std::index_sequence) { - (push_one(std::get(std::move(result))), ...); - } - - template - void push_one(std::tuple_element_t&& val) { - auto* ch = std::get(output_channels_); - if (!ch) return; - try { - ch->push(std::move(val)); - } catch (const ChannelOverflowError&) { - throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label()); - } - } - - template - static std::string output_port_label() { - if constexpr (sizeof...(OutNames) > 0) { - constexpr std::array 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 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, out, std::size_t fifo_capa } // ── make_node factory (NTTP) ────────────────────────────────────────────────── -// -// Usage: -// make_node(capacity) -// make_node(capacity) -// make_node(capacity) // UniqueTag=1 -// make_node(in<"a","b">{}, capacity) -// make_node(in<"a","b">{}, out<"x">{}, capacity) -// No port names template auto make_node(std::size_t fifo_capacity = 5) { return Node, out<>, Label, UniqueTag>(fifo_capacity); } -// in<> only template auto make_node(in, std::size_t fifo_capacity = 5) { return Node, out<>, Label, UniqueTag>(fifo_capacity); } -// out<> only template auto make_node(out, std::size_t fifo_capacity = 5) { return Node, out, Label, UniqueTag>(fifo_capacity); } -// in<> and out<> template auto make_node(in, out, std::size_t fifo_capacity = 5) { diff --git a/include/kpn/pool_node.hpp b/include/kpn/pool_node.hpp new file mode 100644 index 0000000..0900da8 --- /dev/null +++ b/include/kpn/pool_node.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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, + typename OutputTag = out<>, + fixed_string Label = "", + std::size_t UniqueTag = 0> +class PoolNode; + +template +class PoolNode, out, Label, UniqueTag> : public INode { +public: + using F = decltype(Func); + using args_tuple = args_t; + using return_raw = return_t; + using return_tuple = normalised_return_t; + + 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; + static constexpr std::size_t output_count = std::tuple_size_v; + + 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 sched, std::size_t fifo_capacity = 5) + : scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity) + { + init_input_channels(std::make_index_sequence{}); + } + + ~PoolNode() override { stop(); } + + // ── INode ───────────────────────────────────────────────────────────────── + + void start() override { + enable_inputs(std::make_index_sequence{}); + stop_flag_.store(false, std::memory_order_relaxed); + queued_.store(false, std::memory_order_relaxed); + register_callbacks(std::make_index_sequence{}); + 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{}); + // 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 + InputPort input() { + static_assert(I < input_count, "input index out of range"); + return {*this}; + } + + template + OutputPort output() { + static_assert(I < output_count, "output index out of range"); + return {*this}; + } + + // ── Port access — by name ───────────────────────────────────────────────── + + template + auto input() { + constexpr std::size_t idx = index_of(); + static_assert(idx != npos, "unknown input port name"); + return input(); + } + + template + auto output() { + constexpr std::size_t idx = index_of(); + static_assert(idx != npos, "unknown output port name"); + return output(); + } + + // ── Internal channel accessors ──────────────────────────────────────────── + + template + Channel>& input_channel() { + return *std::get(input_channels_); + } + + template + void set_input_channel( + std::shared_ptr>> ch) { + std::get(input_channels_) = std::move(ch); + } + + template + void set_output_channel( + Channel>* ch) { + std::get(output_channels_) = ch; + } + +private: + // ── Channel storage ─────────────────────────────────────────────────────── + + template + void init_input_channels(std::index_sequence) { + ((std::get(input_channels_) = + std::make_shared>>(fifo_capacity_)), + ...); + } + + template + void enable_inputs(std::index_sequence) { + (std::get(input_channels_)->enable(), ...); + } + + template + void disable_inputs(std::index_sequence) { + (std::get(input_channels_)->disable(), ...); + } + + template + void register_callbacks(std::index_sequence) { + (std::get(input_channels_)->set_push_callback( + [this] { on_input_ready(); }), ...); + } + + template + static auto make_input_channel_tuple(std::index_sequence) + -> std::tuple>>...>; + + using input_channels_t = decltype(make_input_channel_tuple( + std::make_index_sequence{})); + + template + static auto make_output_channel_tuple(std::index_sequence) + -> std::tuple>*...>; + + using output_channels_t = decltype(make_output_channel_tuple( + std::make_index_sequence{})); + + // ── 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{}); + if (ready == input_count) + try_submit(compute_priority()); + } + + template + std::size_t count_ready(std::index_sequence) { + return ((std::get(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{}); + return sum / static_cast(input_count); + } + + template + void sum_fill(float& sum, std::index_sequence) { + ((sum += std::get(input_channels_)->capacity() > 0 + ? float(std::get(input_channels_)->approx_size()) + / float(std::get(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( + 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{}); + auto t1 = clock_t::now(); + stats_.record_queue_wait(duration_t(t1 - t0)); + auto cpu0 = NodeStats::cpu_now(); + + if constexpr (std::is_void_v) { + std::apply(Func, args); + } else { + auto result = std::apply(Func, args); + push_outputs(normalise(std::move(result)), + std::make_index_sequence{}); + } + + 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 + args_tuple pop_inputs(std::index_sequence) { + return {pop_one()...}; + } + + template + std::tuple_element_t pop_one() { + auto& ch = *std::get(input_channels_); + std::tuple_element_t val; + if (!ch.try_pop_now(val)) + throw ChannelClosedError{}; + return val; + } + + template + static return_tuple normalise(R&& r) { + if constexpr (is_tuple_v) return std::move(r); + else return std::make_tuple(std::move(r)); + } + + template + void push_outputs(return_tuple&& result, std::index_sequence) { + (push_one_out(std::get(std::move(result))), ...); + } + + template + void push_one_out(std::tuple_element_t&& val) { + auto* ch = std::get(output_channels_); + if (!ch) return; + try { + ch->push(std::move(val)); + } catch (const ChannelOverflowError&) { + throw ChannelOverflowError(ch->capacity(), + "pool node '" + name_ + "' " + output_port_label()); + } + } + + template + static std::string output_port_label() { + if constexpr (sizeof...(OutNames) > 0) { + constexpr std::array names{OutNames.view()...}; + return std::string("output['") + std::string(names[I]) + "']"; + } else { + return "output[" + std::to_string(I) + "]"; + } + } + + // ── State ───────────────────────────────────────────────────────────────── + + std::shared_ptr scheduler_; + std::string name_; + std::size_t fifo_capacity_; + input_channels_t input_channels_; + output_channels_t output_channels_{}; + std::atomic stop_flag_{true}; + std::atomic 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 OutputTag = out<>, + fixed_string Label = "", + std::size_t UniqueTag = 0> +class PoolObjectNode; + +template +class PoolObjectNode, out, Label, UniqueTag> : public INode { +public: + using F = decltype(&Obj::operator()); + using args_tuple = args_t; + using return_raw = return_t; + using return_tuple = normalised_return_t; + + 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; + static constexpr std::size_t output_count = std::tuple_size_v; + + 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 sched, + std::size_t fifo_capacity = 5) + : obj_(obj), scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity) + { + init_input_channels(std::make_index_sequence{}); + } + + ~PoolObjectNode() override { stop(); } + + void start() override { + enable_inputs(std::make_index_sequence{}); + stop_flag_.store(false, std::memory_order_relaxed); + queued_.store(false, std::memory_order_relaxed); + register_callbacks(std::make_index_sequence{}); + 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{}); + } + + 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 InputPort input() { return {*this}; } + template OutputPort output() { return {*this}; } + + template + auto input() { + constexpr std::size_t idx = index_of(); + static_assert(idx != npos, "unknown input port name"); + return input(); + } + template + auto output() { + constexpr std::size_t idx = index_of(); + static_assert(idx != npos, "unknown output port name"); + return output(); + } + + template + Channel>& input_channel() { + return *std::get(input_channels_); + } + template + void set_input_channel(std::shared_ptr>> ch) { + std::get(input_channels_) = std::move(ch); + } + template + void set_output_channel(Channel>* ch) { + std::get(output_channels_) = ch; + } + +private: + template + void init_input_channels(std::index_sequence) { + ((std::get(input_channels_) = + std::make_shared>>(fifo_capacity_)), + ...); + } + template void enable_inputs(std::index_sequence) { (std::get(input_channels_)->enable(), ...); } + template void disable_inputs(std::index_sequence) { (std::get(input_channels_)->disable(), ...); } + template + void register_callbacks(std::index_sequence) { + (std::get(input_channels_)->set_push_callback([this] { on_input_ready(); }), ...); + } + + template + static auto make_input_channel_tuple(std::index_sequence) + -> std::tuple>>...>; + using input_channels_t = decltype(make_input_channel_tuple( + std::make_index_sequence{})); + + template + static auto make_output_channel_tuple(std::index_sequence) + -> std::tuple>*...>; + using output_channels_t = decltype(make_output_channel_tuple( + std::make_index_sequence{})); + + void on_input_ready() { + if (stop_flag_.load(std::memory_order_relaxed)) return; + std::size_t ready = count_ready(std::make_index_sequence{}); + if (ready == input_count) try_submit(compute_priority()); + } + + template + std::size_t count_ready(std::index_sequence) { + return ((std::get(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{}); + return sum / static_cast(input_count); + } + template + void sum_fill(float& sum, std::index_sequence) { + ((sum += std::get(input_channels_)->capacity() > 0 + ? float(std::get(input_channels_)->approx_size()) + / float(std::get(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( + 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{}); + auto t1 = clock_t::now(); + stats_.record_queue_wait(duration_t(t1 - t0)); + auto cpu0 = NodeStats::cpu_now(); + + if constexpr (std::is_void_v) { + std::apply([this](auto&&... a) { obj_(std::forward(a)...); }, args); + } else { + auto result = std::apply([this](auto&&... a) { return obj_(std::forward(a)...); }, args); + push_outputs(normalise(std::move(result)), std::make_index_sequence{}); + } + + 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 + args_tuple pop_inputs(std::index_sequence) { return {pop_one()...}; } + + template + std::tuple_element_t pop_one() { + auto& ch = *std::get(input_channels_); + std::tuple_element_t val; + if (!ch.try_pop_now(val)) throw ChannelClosedError{}; + return val; + } + + template + static return_tuple normalise(R&& r) { + if constexpr (is_tuple_v) return std::move(r); + else return std::make_tuple(std::move(r)); + } + + template + void push_outputs(return_tuple&& result, std::index_sequence) { + (push_one_out(std::get(std::move(result))), ...); + } + template + void push_one_out(std::tuple_element_t&& val) { + auto* ch = std::get(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 scheduler_; + std::string name_; + std::size_t fifo_capacity_; + input_channels_t input_channels_; + output_channels_t output_channels_{}; + std::atomic stop_flag_{true}; + std::atomic queued_{false}; + NodeStats stats_; + NodeErrorHandler error_handler_; + std::chrono::milliseconds max_exec_time_{0}; +}; + +// ── make_pool_node factory (NTTP) ───────────────────────────────────────────── + +template +auto make_pool_node(std::shared_ptr sched, std::size_t fifo_capacity = 5) { + return PoolNode, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity); +} + +template +auto make_pool_node(std::shared_ptr sched, in, + std::size_t fifo_capacity = 5) { + return PoolNode, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity); +} + +template +auto make_pool_node(std::shared_ptr sched, out, + std::size_t fifo_capacity = 5) { + return PoolNode, out, Label, UniqueTag>(std::move(sched), fifo_capacity); +} + +template +auto make_pool_node(std::shared_ptr sched, in, out, + std::size_t fifo_capacity = 5) { + return PoolNode, out, Label, UniqueTag>( + std::move(sched), fifo_capacity); +} + +// ── make_pool_node factory (callable object) ────────────────────────────────── + +template +auto make_pool_node(Obj& obj, std::shared_ptr sched, + std::size_t fifo_capacity = 5) { + return PoolObjectNode, out<>>(obj, std::move(sched), fifo_capacity); +} + +template +auto make_pool_node(Obj& obj, std::shared_ptr sched, in, + std::size_t fifo_capacity = 5) { + return PoolObjectNode, out<>>(obj, std::move(sched), fifo_capacity); +} + +template +auto make_pool_node(Obj& obj, std::shared_ptr sched, out, + std::size_t fifo_capacity = 5) { + return PoolObjectNode, out>(obj, std::move(sched), fifo_capacity); +} + +template +auto make_pool_node(Obj& obj, std::shared_ptr sched, + in, out, + std::size_t fifo_capacity = 5) { + return PoolObjectNode, out>( + obj, std::move(sched), fifo_capacity); +} + +} // namespace kpn diff --git a/include/kpn/python/auto_bind.hpp b/include/kpn/python/auto_bind.hpp new file mode 100644 index 0000000..12a8bd1 --- /dev/null +++ b/include/kpn/python/auto_bind.hpp @@ -0,0 +1,311 @@ +#pragma once +// Auto-binding helpers for KPN++ Python bindings. +// +// Usage in your binding .cpp: +// +// #define KPN_BUILD_PYTHON +// #include +// +// 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, +// kpn::python::Entry, +// kpn::python::Entry +// >; +// +// NB_MODULE(my_kpn, m) { +// kpn::python::bind_network(m); // KPN_BIND_PYTHON behaviour +// kpn::python::bind_debug(m); // KPN_PYTHON_DEBUG behaviour +// } +// +// bind_network registers: +// - Network class (PyNetwork) with auto-registered converters +// - make_(capacity=5) factory for each entry +// - 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 before calling +// bind_network: +// +// namespace kpn { +// template<> struct PythonConverter { +// 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 +#include +#include +#include + +#include +#include +#include +#include + +// ── 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 { + 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(std::move(o)); } +}; + +template<> struct PythonConverter { + 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(std::move(o)); } +}; + +template<> struct PythonConverter { + 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(std::move(o)); } +}; + +template<> struct PythonConverter { + 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(std::move(o)); } +}; + +template<> struct PythonConverter { + 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::move(o)); + } +}; + +} // namespace kpn + +namespace kpn::python { +namespace nb = nanobind; + +// ── Entry ───────────────────────────────────────────────────────── +// Compile-time descriptor for one bindable node function. + +template +struct Entry { + static constexpr auto func = Func; + static constexpr auto name = Name; +}; + +// ── NodeRegistry ─────────────────────────────────────────────────────── + +template +struct NodeRegistry { + using entries_tuple = std::tuple; + 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 +struct entry_port_types { + using args = args_t; + using ret = normalised_return_t>; + using type = decltype(std::tuple_cat(std::declval(), std::declval())); +}; + +// Flat tuple of all types across all entries (may contain duplicates). +template +struct all_types_flat { + using type = decltype(std::tuple_cat( + std::declval::type>()...)); +}; + +template +struct registry_flat_types; + +template +struct registry_flat_types> { + using type = typename all_types_flat::type; +}; + +// Unpack a tuple into unique_types_t (which takes a pack, not a tuple). +// unique_types_t takes Ts... not std::tuple, so we need this bridge. +template +struct unpack_unique; + +template +struct unpack_unique> { + using type = kpn::detail::unique_types_t; +}; + +// SFINAE: does PythonConverter have a 'type_name' member? +template +struct has_type_name : std::false_type {}; + +template +struct has_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(std::toupper(result[0])); + result += "Node"; + return result; +} + +} // namespace detail + +// ── registry_variant_t ───────────────────────────────────────────── +// Deduces std::variant from all port types across the registry. + +template +using registry_variant_t = typename kpn::detail::tuple_to_variant< + typename detail::unpack_unique< + typename detail::registry_flat_types::type>::type +>::type; + +// ── Converter registration ──────────────────────────────────────────────────── + +template +void register_one_type(PyNetwork& net) { + const char* friendly = nullptr; + if constexpr (detail::has_type_name>::value) + friendly = PythonConverter::type_name; + + net.template register_full_type( + [](const T& v) -> nb::object { return PythonConverter::to_python(v); }, + [](nb::object o) -> T { return PythonConverter::from_python(std::move(o)); }, + friendly); +} + +template +void register_types_impl(PyNetwork& net, std::tuple*) { + (register_one_type(net), ...); +} + +template +void register_all_converters(PyNetwork& net) { + using Flat = typename detail::registry_flat_types::type; + using Unique = typename detail::unpack_unique::type; + register_types_impl(net, static_cast(nullptr)); +} + +// ── Per-entry class + factory registration ─────────────────────────────────── + +namespace detail { + +template +void register_one_entry(nb::module_& m) { + using Wrapper = VariantNodeWrapper; + + auto class_name = make_class_name(E::name.view()); + auto make_name = "make_" + std::string(E::name.view()); + + nb::class_>(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> { + return std::make_shared(cap); + }, + nb::arg("capacity") = 5); +} + +template +void register_entries_impl(nb::module_& m, std::tuple*) { + (register_one_entry(m), ...); +} + +} // namespace detail + +// ── bind_network ──────────────────────────────────────────────────── +// Registers: +// - INode — base class (opaque Python handle) +// - Network — PyNetwork with auto-registered converters +// - Node — VariantNodeWrapper for each entry +// - make_() — factory returning shared_ptr + +template +void bind_network(nb::module_& m) { + using Variant = registry_variant_t; + using Net = PyNetwork; + using Entries = typename Registry::entries_tuple; + + nb::class_>(m, "INode"); + + nb::class_(m, "Network") + .def("__init__", [](Net* self) { + new (self) Net(); + register_all_converters(*self); + }) + // add(name, c++_node) + .def("add", [](Net& self, std::string name, + std::shared_ptr> 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{}, + nb::arg("outputs") = std::vector{}, + 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(m, static_cast(nullptr)); +} + +// ── bind_debug ───────────────────────────────────────────────────── +// 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 +void bind_one_debug(nb::module_& m) { + auto name_str = std::string(E::name.view()); + m.def(name_str.c_str(), E::func); +} + +template +void bind_debug_impl(nb::module_& m, std::tuple*) { + (bind_one_debug(m), ...); +} + +} // namespace detail + +template +void bind_debug(nb::module_& m) { + using Entries = typename Registry::entries_tuple; + detail::bind_debug_impl(m, static_cast(nullptr)); +} + +} // namespace kpn::python + +#endif // KPN_BUILD_PYTHON diff --git a/include/kpn/python/bindings.hpp b/include/kpn/python/bindings.hpp index 6a324f9..57554e9 100644 --- a/include/kpn/python/bindings.hpp +++ b/include/kpn/python/bindings.hpp @@ -26,6 +26,9 @@ namespace nb = nanobind; // them via IVariantChannel adapters. The variant only lives at the boundary; // each node's internal Channel stores raw T values. +template +class PyNode; // forward declaration + template 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(). + + void add_node_python(std::string name, nb::object callable, + std::vector in_names, + std::vector out_names, + std::size_t capacity = 5) + { + std::vector 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>( + std::move(callable), + std::move(in_types), + std::move(out_types), + to_python_, + from_python_, + ch_factories_, + capacity)); + } + + // ── Type converter registration ─────────────────────────────────────────── template void register_type( @@ -143,6 +161,52 @@ public: }; } + // register_channel_factory: registers factory for creating input channels. + template + void register_channel_factory() { + ch_factories_[std::type_index(typeid(T))] = + [](std::size_t cap) -> std::shared_ptr { + return std::make_shared>( + std::make_shared>(cap)); + }; + } + + // Backward-compatible alias. + template + void register_tap_factory(std::size_t = 5) { + register_channel_factory(); + } + + // register_full_type: 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 + void register_full_type( + std::function to_py, + std::function from_py, + const char* friendly_name = nullptr) + { + register_type(std::move(to_py), std::move(from_py)); + register_channel_factory(); + 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() 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 make_tap_channel(std::type_index type) { - // Create the right VariantChannel 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 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() or register_tap_factory()"); + 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 - void register_tap_factory(std::size_t capacity = 5) { - auto idx = std::type_index(typeid(T)); - tap_factories_[idx] = [capacity]() -> std::shared_ptr { - auto ch = std::make_shared>(capacity); - return std::make_shared>(std::move(ch)); - }; - } - -private: std::map> nodes_; std::map> adj_; std::vector topo_; @@ -213,19 +264,27 @@ private: std::map> to_python_; std::map> from_python_; - std::map()>> tap_factories_; + + // Channel factory: type → function(capacity) → VChannel. + // Used both for tap channels (read()) and PyNode input channel creation. + std::map(std::size_t)>> ch_factories_; + + // Friendly name → type_index (e.g. "int" → typeid(int)). + std::map type_names_; }; // ── PyNode ─────────────────────────────────────────────────────────── // 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 class PyNode : public IVariantNode { public: using VChannel = IVariantChannel; - using ChannelFactory = std::function(std::size_t capacity)>; + using ChannelFactory = + std::function(std::size_t capacity)>; PyNode(nb::object callable, std::vector 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 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 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 and PyNode 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 with the given nanobind module. +// Prefer bind_network from auto_bind.hpp for new code. template 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")); } diff --git a/include/kpn/scheduler.hpp b/include/kpn/scheduler.hpp new file mode 100644 index 0000000..d449b74 --- /dev/null +++ b/include/kpn/scheduler.hpp @@ -0,0 +1,198 @@ +#pragma once +#include "diagnostics.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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()); + 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 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 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 pq; + std::mutex mx; + }; + + std::optional> try_pop(WorkerQueue& q) { + std::lock_guard lock(q.mx); + if (q.pq.empty()) return std::nullopt; + auto fn = std::move(const_cast(q.pq.top()).fn); + q.pq.pop(); + return fn; + } + + std::optional> 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& 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> queues_; + std::vector workers_; + + std::mutex cv_mx_; + std::condition_variable cv_; + std::mutex drain_mx_; + std::condition_variable drain_cv_; + + std::atomic stopped_{true}; + std::atomic total_{0}; // queued + executing + std::atomic active_{0}; // executing only (for snapshot) + std::atomic next_{0}; // round-robin submit cursor + std::atomic seq_{0}; // tie-break for equal-priority tasks + std::atomic submitted_{0}; + std::atomic completed_{0}; +}; + +} // namespace kpn diff --git a/include/kpn/static_network.hpp b/include/kpn/static_network.hpp index dfa5898..06a9066 100644 --- a/include/kpn/static_network.hpp +++ b/include/kpn/static_network.hpp @@ -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 #include #include +#include #include #include #include @@ -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 nodes; std::vector channels; std::vector resources; + std::vector 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 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 fanout_node_names_; std::vector> channel_probes_; std::vector> resource_probes_; + std::vector> pool_probes_; clock_t::time_point start_time_; #ifdef KPN_WEB_DEBUG uint16_t web_debug_port_{9090}; diff --git a/include/kpn/traits.hpp b/include/kpn/traits.hpp index c09d54f..6381822 100644 --- a/include/kpn/traits.hpp +++ b/include/kpn/traits.hpp @@ -83,4 +83,18 @@ template inline constexpr std::size_t output_count_v = std::tuple_size_v>>; +// ── repeat_tuple: std::tuple with N repetitions ──────────────── + +template> +struct repeat_tuple; + +template +struct repeat_tuple> { + template using always_T = T; + using type = std::tuple...>; +}; + +template +using repeat_tuple_t = typename repeat_tuple::type; + } // namespace kpn diff --git a/include/kpn/variant_node.hpp b/include/kpn/variant_node.hpp index cacaa6e..0af32e7 100644 --- a/include/kpn/variant_node.hpp +++ b/include/kpn/variant_node.hpp @@ -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 . template -struct PythonConverter { - static_assert(sizeof(T) == 0, - "PythonConverter must be specialised for each type used in a PyNetwork"); -}; +struct PythonConverter {}; } // namespace kpn diff --git a/include/kpn/web_debug.hpp b/include/kpn/web_debug.hpp index 2805969..cd325af 100644 --- a/include/kpn/web_debug.hpp +++ b/include/kpn/web_debug.hpp @@ -47,7 +47,8 @@ static std::pair parse_edge_name(const std::string& nam static std::string to_json(const std::vector& nodes, const std::vector& channels, const std::vector& resources = {}, - double elapsed_s = 0.0) { + double elapsed_s = 0.0, + const std::vector& pools = {}) { std::ostringstream o; o << std::fixed; o.precision(2); @@ -97,6 +98,20 @@ static std::string to_json(const std::vector& 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})`); } diff --git a/python/kpn_python.cpp b/python/kpn_python.cpp index 583d9cb..3dd24b6 100644 --- a/python/kpn_python.cpp +++ b/python/kpn_python.cpp @@ -1,164 +1,39 @@ #define KPN_BUILD_PYTHON -#include -#include -#include -#include -#include +#include #include -#include namespace nb = nanobind; using namespace kpn; using namespace kpn::python; -// ── Node functions for the hello-pipeline examples ──────────────────────────── +// ── Demo node functions (hello-pipeline) ────────────────────────────────────── static int produce() { return 42; } static int double_it(int x) { return x * 2; } static void print_it(int x) { std::cout << "result: " << x << '\n'; } -// ── Variant type ────────────────────────────────────────────────────────────── -// Deduplicated port types across all registered node functions. -// produce: () → int | double_it: int → int | print_it: int → void -// Unique types: int +// ── Registry ────────────────────────────────────────────────────────────────── +// Variant type is auto-deduced as std::variant from the port types above. -using KpnVariant = std::variant; -using Net = PyNetwork; -using ProduceNode = VariantNodeWrapper; -using DoubleItNode= VariantNodeWrapper; -using PrintItNode = VariantNodeWrapper; +using DemoNodes = NodeRegistry< + Entry, + Entry, + Entry +>; -// ── Converter helpers for int ───────────────────────────────────────────────── - -static nb::object int_to_py(const KpnVariant& v) { - return nb::int_(std::get(v)); -} - -static KpnVariant int_from_py(nb::object o) { - return KpnVariant{ nb::cast(o) }; -} - -// ── Type name resolver (extensible) ────────────────────────────────────────── - -static std::type_index resolve_type(const std::string& name) { - if (name == "int") return std::type_index(typeid(int)); - throw std::runtime_error("unknown type name '" + name + - "' — only 'int' is registered in this module"); -} - -// ── Ensure converters are registered on a Net instance ─────────────────────── - -static void ensure_converters(Net& net) { - net.register_type( - [](const int& v) -> nb::object { return nb::int_(v); }, - [](nb::object o) -> int { return nb::cast(o); } - ); - net.register_tap_factory(); -} +// ── Module ──────────────────────────────────────────────────────────────────── NB_MODULE(kpn_python, m) { m.doc() = "KPN++ Python bindings — Kahn Process Network library"; - // ── IVariantNode base ───────────────────────────────────────────────────── - nb::class_>(m, "INode"); + // Registers: Network, INode, ProduceNode, DoubleItNode, PrintItNode, + // make_produce(), make_double_it(), make_print_it() + bind_network(m); - // ── Concrete C++ node wrappers ───────────────────────────────────────────── - // Factories return shared_ptr so Python can pass them to net.add(). - nb::class_>(m, "ProduceNode") - .def("__init__", [](ProduceNode* self, std::size_t cap) { - new (self) ProduceNode(cap); - }, nb::arg("capacity") = 5); - - nb::class_>(m, "DoubleItNode") - .def("__init__", [](DoubleItNode* self, std::size_t cap) { - new (self) DoubleItNode(cap); - }, nb::arg("capacity") = 5); - - nb::class_>(m, "PrintItNode") - .def("__init__", [](PrintItNode* self, std::size_t cap) { - new (self) PrintItNode(cap); - }, nb::arg("capacity") = 5); - - // Expose factory functions that return shared_ptr — these are what net.add() accepts. - m.def("make_produce", [](std::size_t cap) -> std::shared_ptr> { - return std::make_shared(cap); - }, nb::arg("capacity") = 5); - m.def("make_double_it", [](std::size_t cap) -> std::shared_ptr> { - return std::make_shared(cap); - }, nb::arg("capacity") = 5); - m.def("make_print_it", [](std::size_t cap) -> std::shared_ptr> { - return std::make_shared(cap); - }, nb::arg("capacity") = 5); - - // ── Network ─────────────────────────────────────────────────────────────── - nb::class_(m, "Network") - .def(nb::init<>()) - - // add(name, c++_node) — for pre-constructed C++ nodes - .def("add", [](Net& self, std::string name, - std::shared_ptr> node) { - self.add(std::move(name), std::move(node)); - }, nb::arg("name"), nb::arg("node")) - - // add_node(name, callable, inputs=[type_names], outputs=[type_names]) - // Creates a pure Python processing node. - .def("add_node", [](Net& self, - std::string name, - nb::object callable, - std::vector in_names, - std::vector out_names, - std::size_t capacity) - { - std::vector in_types, out_types; - for (auto& s : in_names) in_types.push_back(resolve_type(s)); - for (auto& s : out_names) out_types.push_back(resolve_type(s)); - - std::map> to_py; - std::map> from_py; - std::map::ChannelFactory> ch_factories; - - auto int_idx = std::type_index(typeid(int)); - to_py[int_idx] = int_to_py; - from_py[int_idx] = int_from_py; - ch_factories[int_idx] = [](std::size_t cap) -> std::shared_ptr> { - auto ch = std::make_shared>(cap); - return std::make_shared>(std::move(ch)); - }; - - auto node = std::make_shared>( - std::move(callable), - std::move(in_types), - std::move(out_types), - std::move(to_py), - std::move(from_py), - std::move(ch_factories), - capacity - ); - self.add(std::move(name), std::move(node)); - }, - nb::arg("name"), nb::arg("callable"), - nb::arg("inputs") = std::vector{}, - nb::arg("outputs") = std::vector{}, - nb::arg("capacity") = 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) - - // read(node, out_idx=0) — blocking pop from a C++ node's output port into Python - .def("read", [](Net& self, const std::string& node, std::size_t out_idx) { - ensure_converters(self); - return self.read(node, out_idx); - }, nb::arg("node"), nb::arg("out_idx") = 0) - - // write(node, in_idx, value) — push a Python value into a node's input port - .def("write", [](Net& self, const std::string& node, - std::size_t in_idx, nb::object value) { - ensure_converters(self); - self.write(node, in_idx, std::move(value)); - }, nb::arg("node"), nb::arg("in_idx"), nb::arg("value")); + // Also expose raw functions for direct testing (no network needed): + // kpn.produce() → 42 + // kpn.double_it(5) → 10 + // kpn.print_it(84) → prints "result: 84", returns None + bind_debug(m); } diff --git a/scripts/render_readme.py b/scripts/render_readme.py new file mode 100644 index 0000000..c0fbc08 --- /dev/null +++ b/scripts/render_readme.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Render README.md from README.md.in by expanding @snippet directives. + +Directive format (in README.md.in): + + +Snippet tags (in C++ source files): + // [snippet: snippet_name] + ...content... + // [/snippet: snippet_name] + +In Python files use # instead of //. +""" + +import re +import sys +import textwrap +from pathlib import Path + +ROOT = Path(__file__).parent.parent +TEMPLATE = ROOT / "README.md.in" +OUTPUT = ROOT / "README.md" + +DIRECTIVE_RE = re.compile(r'') + +LANG_MAP = {'.cpp': 'cpp', '.hpp': 'cpp', '.h': 'cpp', '.py': 'python', '.c': 'c'} + + +def comment_prefix(path: Path) -> str: + return '#' if path.suffix == '.py' else '//' + + +def extract_snippet(file_path: Path, name: str) -> str: + prefix = comment_prefix(file_path) + open_tag = f'{prefix} [snippet: {name}]' + close_tag = f'{prefix} [/snippet: {name}]' + + text = file_path.read_text() + lines = text.splitlines() + + in_snippet = False + found = False + result = [] + + for line in lines: + stripped = line.strip() + if stripped == open_tag: + if in_snippet: + raise ValueError(f"Nested open tag for '{name}' in {file_path}") + in_snippet = True + found = True + continue + if stripped == close_tag: + if not in_snippet: + raise ValueError(f"Unexpected close tag for '{name}' in {file_path}") + in_snippet = False + continue + if in_snippet: + result.append(line) + + if not found: + raise ValueError(f"Snippet '{name}' not found in {file_path}") + if in_snippet: + raise ValueError(f"Unclosed snippet '{name}' in {file_path}") + + # Strip leading/trailing blank lines + while result and not result[0].strip(): + result.pop(0) + while result and not result[-1].strip(): + result.pop() + + # Dedent common leading whitespace + content = textwrap.dedent('\n'.join(result)) + return content + + +def lang_for(file_path: Path) -> str: + return LANG_MAP.get(file_path.suffix, '') + + +def render(template: str) -> str: + errors = [] + + def replace(m: re.Match) -> str: + rel_path = m.group(1) + name = m.group(2) + file_path = ROOT / rel_path + if not file_path.exists(): + errors.append(f"File not found: {file_path}") + return m.group(0) + try: + content = extract_snippet(file_path, name) + except ValueError as e: + errors.append(str(e)) + return m.group(0) + lang = lang_for(file_path) + return f'```{lang}\n{content}\n```' + + result = DIRECTIVE_RE.sub(replace, template) + + if errors: + for err in errors: + print(f"ERROR: {err}", file=sys.stderr) + sys.exit(1) + + return result + + +def main() -> None: + if not TEMPLATE.exists(): + print(f"Error: {TEMPLATE} not found", file=sys.stderr) + sys.exit(1) + + rendered = render(TEMPLATE.read_text()) + OUTPUT.write_text(rendered) + print(f"Rendered {TEMPLATE.name} → {OUTPUT.name}") + + +if __name__ == '__main__': + main() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2f1a4d0..a8f7583 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,6 +33,8 @@ add_executable(kpn_tests test_network.cpp test_static_network.cpp test_shared_resource.cpp + test_pool_node.cpp + test_scheduler.cpp ) target_link_libraries(kpn_tests PRIVATE diff --git a/tests/test_channel.cpp b/tests/test_channel.cpp index a45c203..50b11d0 100644 --- a/tests/test_channel.cpp +++ b/tests/test_channel.cpp @@ -1,6 +1,8 @@ #include +#include #include #include +#include using namespace kpn; @@ -70,13 +72,21 @@ TEST_CASE("push to disabled channel is silently dropped", "[channel]") { REQUIRE(ch.size() == 0); } -TEST_CASE("disable clears existing queue contents", "[channel]") { +TEST_CASE("disable stops accepting and unblocks pop", "[channel]") { + // With the SPSC lock-free ring, disable() does not drain the ring immediately; + // items are freed when the Channel is destroyed. What it must do is: + // 1. reject further pushes (drops silently) + // 2. unblock any waiting pop() (throws ChannelClosedError) Channel ch(5); ch.push(1); ch.push(2); REQUIRE(ch.size() == 2); ch.disable(); - REQUIRE(ch.size() == 0); + // Further pushes are dropped + ch.push(3); + REQUIRE(ch.size() <= 2); + // pop() must throw even though items remain in the ring + REQUIRE_THROWS_AS(ch.pop(), ChannelClosedError); } TEST_CASE("enable re-accepts pushes after disable", "[channel]") { @@ -97,3 +107,71 @@ TEST_CASE("large type stored as shared_ptr — no copy on pop", "[channel]") { auto out = ch.pop(); REQUIRE(out.tag == 123); } + +// ── ChannelDataSize / bytes_pushed tests ───────────────────────────────────── + +TEST_CASE("bytes_pushed uses sizeof(T) by default for POD type", "[channel][bandwidth]") { + Channel ch(10); + ch.push(1); + ch.push(2); + ch.push(3); + ch.pop(); ch.pop(); ch.pop(); + + auto snap = ch.snapshot("test"); + REQUIRE(snap.pushes == 3); + REQUIRE(snap.bytes_pushed == 3 * sizeof(int)); +} + +TEST_CASE("bytes_pushed uses sizeof(T) by default for large struct", "[channel][bandwidth]") { + struct Blob { char data[256]; }; + Channel ch(10); + ch.push(Blob{}); + ch.push(Blob{}); + + auto snap = ch.snapshot("test"); + REQUIRE(snap.bytes_pushed == 2 * sizeof(Blob)); +} + +// A fake heap-owning type whose logical payload size differs from sizeof. +struct FakeFrame { + std::vector pixels; +}; + +// Specialise ChannelDataSize so the channel counts actual pixel bytes. +template<> +struct kpn::ChannelDataSize { + static std::size_t bytes(const FakeFrame& f) { return f.pixels.size(); } +}; + +TEST_CASE("bytes_pushed uses ChannelDataSize specialisation for heap-owning type", "[channel][bandwidth]") { + Channel ch(10); + ch.push(FakeFrame{std::vector(1000)}); + ch.push(FakeFrame{std::vector(2000)}); + + auto snap = ch.snapshot("test"); + REQUIRE(snap.pushes == 2); + REQUIRE(snap.bytes_pushed == 3000); + // item_bytes is still sizeof(FakeFrame) — the struct header + REQUIRE(snap.item_bytes == sizeof(FakeFrame)); +} + +TEST_CASE("bandwidth_mbs is non-zero and correct for heap-owning type", "[channel][bandwidth]") { + Channel ch(10); + // Push 10 frames of 1 MB each + for (int i = 0; i < 10; ++i) + ch.push(FakeFrame{std::vector(1'000'000)}); + + auto snap = ch.snapshot("test"); + // 10 MB over 1 second => 10.0 MB/s + REQUIRE(snap.bandwidth_mbs(1.0) == Catch::Approx(10.0).epsilon(1e-6)); + // Without the fix (using sizeof), this would have been ~40 bytes/s ≈ 0.00004 MB/s. + REQUIRE(snap.bandwidth_mbs(1.0) > 1.0); +} + +TEST_CASE("bandwidth_mbs returns 0 when elapsed_s is zero or negative", "[channel][bandwidth]") { + Channel ch(5); + ch.push(42); + auto snap = ch.snapshot("test"); + REQUIRE(snap.bandwidth_mbs(0.0) == 0.0); + REQUIRE(snap.bandwidth_mbs(-1.0) == 0.0); +} diff --git a/tests/test_pool_node.cpp b/tests/test_pool_node.cpp new file mode 100644 index 0000000..1d90b82 --- /dev/null +++ b/tests/test_pool_node.cpp @@ -0,0 +1,241 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace kpn; + +static int double_it(int x) { return x * 2; } +static std::tuple split_it(int x) { return {x, float(x) * 0.5f}; } +static void consume_it(int x) { (void)x; } + +// ── ThreadPool ──────────────────────────────────────────────────────────────── + +TEST_CASE("thread pool starts and stops cleanly", "[scheduler]") { + ThreadPool pool(2); + pool.start(); + pool.stop(); +} + +TEST_CASE("thread pool executes submitted tasks", "[scheduler]") { + ThreadPool pool(2); + pool.start(); + + std::atomic counter{0}; + for (int i = 0; i < 10; ++i) + pool.submit([&] { counter.fetch_add(1); }); + + pool.drain(); + REQUIRE(counter.load() == 10); + pool.stop(); +} + +TEST_CASE("thread pool drain waits for all tasks", "[scheduler]") { + ThreadPool pool(1); + pool.start(); + + std::atomic done{false}; + pool.submit([&] { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + done.store(true); + }); + pool.drain(); + REQUIRE(done.load()); + pool.stop(); +} + +TEST_CASE("thread pool priority: higher priority tasks run first", "[scheduler]") { + ThreadPool pool(1); // single thread so order is deterministic + pool.start(); + + // Submit a task that blocks the worker, then queue two tasks with + // different priorities. When the blocker finishes, the high-priority + // task should run before the low-priority one. + std::vector order; + std::mutex order_mutex; + + std::atomic blocker_done{false}; + pool.submit([&] { + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + blocker_done.store(true); + }, 0.5f); + + // Wait until blocker is running, then enqueue the two ordered tasks. + while (!blocker_done.load()) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + + pool.submit([&] { std::lock_guard g(order_mutex); order.push_back(1); }, 0.1f); + pool.submit([&] { std::lock_guard g(order_mutex); order.push_back(2); }, 0.9f); + + pool.drain(); + REQUIRE(order == std::vector{2, 1}); + pool.stop(); +} + +// ── PoolNode ────────────────────────────────────────────────────────────────── + +TEST_CASE("pool node input/output counts", "[pool_node]") { + STATIC_REQUIRE(PoolNode::input_count == 1); + STATIC_REQUIRE(PoolNode::output_count == 1); + STATIC_REQUIRE(PoolNode::output_count == 2); + STATIC_REQUIRE(PoolNode::output_count == 0); +} + +TEST_CASE("pool node processes items end-to-end", "[pool_node]") { + auto pool = std::make_shared(2); + pool->start(); + + auto node = make_pool_node(pool); + Channel out_ch(10); + node.set_output_channel<0>(&out_ch); + node.start(); + + node.input_channel<0>().push(21); + int result = out_ch.pop(); + + node.stop(); + pool->stop(); + + REQUIRE(result == 42); +} + +TEST_CASE("pool node processes multiple items in order", "[pool_node]") { + auto pool = std::make_shared(2); + pool->start(); + + auto node = make_pool_node(pool, 20); // capacity 20 + Channel out_ch(20); + node.set_output_channel<0>(&out_ch); + node.start(); + + constexpr int N = 10; + for (int i = 0; i < N; ++i) + node.input_channel<0>().push(i); + + std::vector results; + for (int i = 0; i < N; ++i) + results.push_back(out_ch.pop()); + + node.stop(); + pool->stop(); + + REQUIRE(results.size() == N); + for (int i = 0; i < N; ++i) + REQUIRE(results[i] == i * 2); +} + +TEST_CASE("pool node stop is clean with no deadlock", "[pool_node]") { + auto pool = std::make_shared(2); + pool->start(); + + auto node = make_pool_node(pool); + node.start(); + // Node is idle (no input pushed) — stop must return without deadlock. + node.stop(); + REQUIRE_FALSE(node.running()); + + pool->stop(); +} + +TEST_CASE("pool node two-stage pipeline produces correct count", "[pool_node]") { + auto pool = std::make_shared(4); + pool->start(); + + auto src = make_pool_node(pool); + auto transform = make_pool_node(pool); + Channel out_ch(20); + + // Wire src output → transform input channel, transform output → out_ch. + src.set_output_channel<0>(&transform.input_channel<0>()); + transform.set_output_channel<0>(&out_ch); + + src.start(); + transform.start(); + + constexpr int N = 5; + for (int i = 1; i <= N; ++i) + src.input_channel<0>().push(i); + + std::vector results; + for (int i = 0; i < N; ++i) + results.push_back(out_ch.pop()); + + // Stop nodes before they (and their channels) go out of scope. + src.stop(); + transform.stop(); + pool->stop(); + + REQUIRE(results.size() == static_cast(N)); + for (int i = 0; i < N; ++i) + REQUIRE(results[i] == (i + 1) * 4); // double_it twice +} + +// ── InterruptNode ───────────────────────────────────────────────────────────── + +namespace { +static std::atomic g_interrupt_counter{0}; +static int interrupt_produce() { return g_interrupt_counter.fetch_add(1); } +} // namespace + +TEST_CASE("interrupt node fires on each trigger", "[interrupt_node]") { + auto pool = std::make_shared(2); + pool->start(); + + g_interrupt_counter.store(0); + auto node = make_interrupt_node(pool, out<>{}); + Channel out_ch(20); + node.set_output_channel<0>(&out_ch); + node.start(); + + auto trigger = node.get_trigger(); + constexpr int N = 5; + for (int i = 0; i < N; ++i) trigger(); + + std::vector results; + for (int i = 0; i < N; ++i) + results.push_back(out_ch.pop()); + + node.stop(); + pool->stop(); + + REQUIRE(results.size() == static_cast(N)); +} + +TEST_CASE("interrupt node does not fire without trigger", "[interrupt_node]") { + auto pool = std::make_shared(2); + pool->start(); + + g_interrupt_counter.store(0); + auto node = make_interrupt_node(pool, out<>{}); + Channel out_ch(5); + node.set_output_channel<0>(&out_ch); + node.start(); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + // No trigger fired — output channel should be empty. + REQUIRE(out_ch.approx_size() == 0); + + node.stop(); + pool->stop(); +} + +TEST_CASE("interrupt node: trigger after stop is ignored", "[interrupt_node]") { + auto pool = std::make_shared(2); + pool->start(); + + g_interrupt_counter.store(0); + auto node = make_interrupt_node(pool, out<>{}); + Channel out_ch(5); + node.set_output_channel<0>(&out_ch); + node.start(); + + auto trigger = node.get_trigger(); + node.stop(); + trigger(); // should be a no-op + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + REQUIRE(out_ch.approx_size() == 0); + pool->stop(); +} diff --git a/tests/test_scheduler.cpp b/tests/test_scheduler.cpp new file mode 100644 index 0000000..18f3fdd --- /dev/null +++ b/tests/test_scheduler.cpp @@ -0,0 +1,229 @@ +#include +#include + +#include +#include +#include +#include +#include + +using namespace kpn; +using namespace std::chrono_literals; + +// ── basic execution ─────────────────────────────────────────────────────────── + +TEST_CASE("scheduler runs submitted tasks", "[scheduler]") { + ThreadPool pool(2); + pool.start(); + + std::atomic counter{0}; + for (int i = 0; i < 100; ++i) + pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); }); + + pool.drain(); + REQUIRE(counter.load() == 100); + pool.stop(); +} + +TEST_CASE("scheduler single thread executes all tasks", "[scheduler]") { + ThreadPool pool(1); + pool.start(); + + std::atomic counter{0}; + for (int i = 0; i < 50; ++i) + pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); }); + + pool.drain(); + REQUIRE(counter.load() == 50); + pool.stop(); +} + +// ── drain ───────────────────────────────────────────────────────────────────── + +TEST_CASE("drain returns immediately when pool is idle", "[scheduler]") { + ThreadPool pool(2); + pool.start(); + pool.drain(); // nothing submitted — should return immediately + pool.stop(); +} + +TEST_CASE("drain waits for all tasks to complete", "[scheduler]") { + ThreadPool pool(4); + pool.start(); + + std::atomic counter{0}; + constexpr int N = 200; + + for (int i = 0; i < N; ++i) { + pool.submit([&counter]{ + std::this_thread::sleep_for(1ms); + counter.fetch_add(1, std::memory_order_relaxed); + }); + } + + pool.drain(); + REQUIRE(counter.load() == N); + pool.stop(); +} + +TEST_CASE("drain is safe to call multiple times", "[scheduler]") { + ThreadPool pool(2); + pool.start(); + + std::atomic counter{0}; + pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); }); + pool.drain(); + REQUIRE(counter.load() == 1); + + pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); }); + pool.drain(); + REQUIRE(counter.load() == 2); + + pool.stop(); +} + +// ── priority ordering ───────────────────────────────────────────────────────── + +TEST_CASE("higher priority tasks run before lower priority on single thread", "[scheduler]") { + // Single thread guarantees serial execution — we can observe order. + ThreadPool pool(1); + pool.start(); + + // Pause the worker so we can fill the queue before it drains. + std::mutex gate; + gate.lock(); + pool.submit([&gate]{ std::lock_guard lg(gate); }); // blocks worker + + std::vector order; + std::mutex order_mx; + + for (float p : {0.1f, 0.9f, 0.5f, 0.8f, 0.2f}) { + pool.submit([p, &order, &order_mx]{ + std::lock_guard lg(order_mx); + order.push_back(p); + }, p); + } + + gate.unlock(); // release the blocking task + pool.drain(); + pool.stop(); + + // order should be descending by priority + REQUIRE(order.size() == 5); + for (std::size_t i = 1; i < order.size(); ++i) + REQUIRE(order[i - 1] >= order[i]); +} + +TEST_CASE("equal priority tasks execute in FIFO order on single thread", "[scheduler]") { + ThreadPool pool(1); + pool.start(); + + std::mutex gate; + gate.lock(); + pool.submit([&gate]{ std::lock_guard lg(gate); }); + + std::vector order; + std::mutex order_mx; + + for (int i = 0; i < 5; ++i) { + pool.submit([i, &order, &order_mx]{ + std::lock_guard lg(order_mx); + order.push_back(i); + }, 0.5f); // all same priority + } + + gate.unlock(); + pool.drain(); + pool.stop(); + + REQUIRE(order == std::vector{0, 1, 2, 3, 4}); +} + +// ── total_ / active_ accounting ─────────────────────────────────────────────── + +TEST_CASE("snapshot queue depth and active counts are consistent", "[scheduler]") { + ThreadPool pool(2); + pool.start(); + + // While tasks are running, active should be > 0 and total >= active. + std::atomic running{false}; + std::mutex gate; + gate.lock(); + + for (int i = 0; i < 4; ++i) { + pool.submit([&gate, &running]{ + running.store(true, std::memory_order_relaxed); + std::lock_guard lg(gate); + }); + } + + // Spin until at least one task has started + while (!running.load(std::memory_order_relaxed)) + std::this_thread::yield(); + + auto snap = pool.snapshot("test"); + REQUIRE(snap.active_count > 0); + REQUIRE(snap.queue_depth + snap.active_count > 0); + + gate.unlock(); + pool.drain(); + + auto snap2 = pool.snapshot("test"); + REQUIRE(snap2.active_count == 0); + REQUIRE(snap2.queue_depth == 0); + + pool.stop(); +} + +TEST_CASE("submitted and completed counters are accurate", "[scheduler]") { + ThreadPool pool(3); + pool.start(); + + constexpr int N = 60; + for (int i = 0; i < N; ++i) + pool.submit([]{ std::this_thread::yield(); }); + + pool.drain(); + auto snap = pool.snapshot("test"); + REQUIRE(snap.tasks_submitted == static_cast(N)); + REQUIRE(snap.tasks_completed == static_cast(N)); + + pool.stop(); +} + +// ── work stealing ───────────────────────────────────────────────────────────── + +TEST_CASE("work stealing: all tasks complete with uneven initial distribution", "[scheduler]") { + // 4-thread pool. Submit a burst to ensure some threads start empty and must steal. + ThreadPool pool(4); + pool.start(); + + std::atomic counter{0}; + constexpr int N = 400; + + for (int i = 0; i < N; ++i) + pool.submit([&counter]{ + std::this_thread::sleep_for(100us); + counter.fetch_add(1, std::memory_order_relaxed); + }); + + pool.drain(); + REQUIRE(counter.load() == N); + pool.stop(); +} + +TEST_CASE("work stealing: tasks complete with more threads than initial queue targets", "[scheduler]") { + // With round-robin, some threads may get no tasks initially and must steal. + constexpr std::size_t THREADS = 8; + ThreadPool pool(THREADS); + pool.start(); + + std::atomic counter{0}; + // Submit fewer tasks than threads so most threads must steal + for (int i = 0; i < 4; ++i) + pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); }); + + pool.drain(); + REQUIRE(counter.load() == 4); + pool.stop(); +}