Performance improvements, better readme and complete python bindings
Some checks failed
🧪 Test / test (push) Failing after 28m30s
Some checks failed
🧪 Test / test (push) Failing after 28m30s
This commit is contained in:
parent
c39db82763
commit
f6bcaa15b0
@ -48,6 +48,12 @@ if(KPN_BUILD_TESTS)
|
|||||||
add_subdirectory(tests)
|
add_subdirectory(tests)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# ── Benchmarks ────────────────────────────────────────────────────────────────
|
||||||
|
option(KPN_BUILD_BENCHMARKS "Build benchmarks" OFF)
|
||||||
|
if(KPN_BUILD_BENCHMARKS)
|
||||||
|
add_subdirectory(benchmarks)
|
||||||
|
endif()
|
||||||
|
|
||||||
# ── Python bindings ───────────────────────────────────────────────────────────
|
# ── Python bindings ───────────────────────────────────────────────────────────
|
||||||
if(KPN_BUILD_PYTHON)
|
if(KPN_BUILD_PYTHON)
|
||||||
find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED)
|
find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED)
|
||||||
@ -69,3 +75,14 @@ endif()
|
|||||||
if(KPN_BUILD_EXAMPLES)
|
if(KPN_BUILD_EXAMPLES)
|
||||||
add_subdirectory(examples)
|
add_subdirectory(examples)
|
||||||
endif()
|
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()
|
||||||
|
|||||||
395
README.md
395
README.md
@ -50,31 +50,55 @@ A node wraps any callable. Its input types are taken from the function's paramet
|
|||||||
```cpp
|
```cpp
|
||||||
#include <kpn/kpn.hpp>
|
#include <kpn/kpn.hpp>
|
||||||
using namespace kpn;
|
using namespace kpn;
|
||||||
|
```
|
||||||
|
|
||||||
// Single input, single output
|
Source, transform, and sink — from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp):
|
||||||
int double_it(int x) { return x * 2; }
|
|
||||||
|
|
||||||
// Multi-output — must return std::tuple
|
```cpp
|
||||||
std::tuple<cv::Mat, cv::Mat> split(cv::Mat frame) { ... }
|
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
|
Multi-output node returning a tuple — from [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp):
|
||||||
void display(cv::Mat frame) { cv::imshow("out", frame); }
|
|
||||||
|
```cpp
|
||||||
|
// Multi-output: returns (key, value) as a tuple — KPN++ routes each element
|
||||||
|
// to its own output port automatically.
|
||||||
|
static std::tuple<std::string, std::string> 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
|
### Creating Nodes
|
||||||
|
|
||||||
|
**Index-only ports** (from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp)):
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
// No port names (index-only access)
|
auto src = make_node<produce>(5);
|
||||||
auto node = make_node<double_it>(/*fifo_capacity=*/5);
|
auto dbl = make_node<double_it>(5);
|
||||||
|
auto sink = make_node<print_it>(5);
|
||||||
|
```
|
||||||
|
|
||||||
// Named input ports only
|
**Named ports** (from [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp)):
|
||||||
auto node = make_node<double_it>(in<"value">{}, 5);
|
|
||||||
|
|
||||||
// Named input and output ports
|
```cpp
|
||||||
auto node = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
|
// tokenise: no inputs, one named output "words"
|
||||||
|
auto tok = make_node<tokenise>(out<"words">{}, 4);
|
||||||
|
|
||||||
// Named output ports only (e.g. a source with no inputs)
|
// count_words: named input "words", named outputs "count" and "words"
|
||||||
auto node = make_node<capture>(out<"colour","grey">{}, 5);
|
auto cnt = make_node<count_words>(in<"words">{}, out<"count", "words">{}, 4);
|
||||||
|
|
||||||
|
// report: two named inputs
|
||||||
|
auto snk = make_node<report>(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<capture>(out<"colour","grey">{}, 8);
|
||||||
```
|
```
|
||||||
|
|
||||||
Port names are NTTP `fixed_string` values — resolved entirely at compile time, zero runtime cost.
|
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.
|
`Network` is **non-owning** — declare nodes first, then register them. Nodes must outlive the network.
|
||||||
|
|
||||||
```cpp
|
From [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp):
|
||||||
auto src = make_node<produce>(in<"x">{}, out<"value">{}, 5);
|
|
||||||
auto proc = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
|
|
||||||
|
|
||||||
|
```cpp
|
||||||
Network net;
|
Network net;
|
||||||
net.add("src", src)
|
net.add("tok", tok)
|
||||||
.add("proc", proc)
|
.add("cnt", cnt)
|
||||||
.connect("src", src.output<0>(), "proc", proc.input<0>()) // by index
|
.add("snk", snk)
|
||||||
.connect("src", src.template output<"value">(), "proc", proc.template input<"value">()) // by name
|
.connect("tok", tok.template output<"words">(), "cnt", cnt.template input<"words">())
|
||||||
.build(); // runs cycle detection — throws NetworkCycleError on cycles
|
.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();
|
net.start();
|
||||||
// ... do work ...
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||||
net.stop();
|
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.
|
> **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
|
### Channel Semantics
|
||||||
@ -113,14 +140,36 @@ net.stop();
|
|||||||
|
|
||||||
Large types (`sizeof > 8` or non-trivially-copyable) are stored as `std::shared_ptr<const T>` inside the channel — no copies, shared immutable ownership. Small trivially-copyable types are stored by value.
|
Large types (`sizeof > 8` or non-trivially-copyable) are stored as `std::shared_ptr<const T>` 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
|
```cpp
|
||||||
template<> struct kpn::channel_storage_policy<MyType> {
|
// 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<Tag> {
|
||||||
static constexpr bool by_value = true;
|
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<NodeSnapshot>& nodes,
|
||||||
|
const std::vector<ChannelSnapshot>& 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<int>(c.fill_pct()) << "% "
|
||||||
|
<< "overflows=" << c.overflows;
|
||||||
|
std::cout << '\n';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
### Shutdown
|
### Shutdown
|
||||||
|
|
||||||
`node.stop()` / `net.stop()`:
|
`node.stop()` / `net.stop()`:
|
||||||
@ -171,20 +220,155 @@ top.start();
|
|||||||
|
|
||||||
## Display / GUI Nodes
|
## 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<cv::Mat>` 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
|
```cpp
|
||||||
Channel<cv::Mat> result_ch(8);
|
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
|
||||||
comp.set_output_channel<0>(&result_ch);
|
kpn::in<"composite", "edges">,
|
||||||
// ... build and start network ...
|
cv::Mat, cv::Mat> {
|
||||||
|
public:
|
||||||
// Main thread display loop
|
DisplayNode() : MainThreadNode(8) {
|
||||||
while (true) {
|
cv::namedWindow("Cell Shade", cv::WINDOW_NORMAL);
|
||||||
cv::Mat frame;
|
cv::namedWindow("Edge Mask", cv::WINDOW_NORMAL);
|
||||||
if (!result_ch.try_pop(frame, std::chrono::milliseconds(100))) continue;
|
cv::resizeWindow("Cell Shade", 1280, 720);
|
||||||
cv::imshow("output", frame);
|
cv::resizeWindow("Edge Mask", 640, 360);
|
||||||
if (cv::waitKey(1) == 'q') break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
~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<cv::Mat, cv::Mat> 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()};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full network wiring:**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto src = make_node<capture> (out<"colour","grey">{}, 8);
|
||||||
|
auto gray_node = make_node<to_gray> (in<"bgr">{}, out<"gray">{}, 8);
|
||||||
|
auto edge_node = make_node<edges_fn> (in<"gray">{}, out<"edges">{}, 8);
|
||||||
|
auto quant = make_node<quantise> (in<"bgr">{}, out<"quantised">{}, 8);
|
||||||
|
auto comp = make_node<composite>(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<generate>(out<"kv">{}, 4);
|
||||||
|
auto par = make_node<parse> (in<"kv">{}, out<"key", "value">{}, 4);
|
||||||
|
auto keys = make_node<print_key> (in<"key">{}, 4);
|
||||||
|
auto vals = make_node<print_value>(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();
|
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<int,int>` (serial concurrency) with
|
||||||
|
`tbb::flow::broadcast_node<int>` 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<const T>` — 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<int,int> n(g, tbb::flow::serial, [](int x){ return x*2; });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-output node:**
|
||||||
|
```cpp
|
||||||
|
// KPN — return a tuple
|
||||||
|
std::tuple<cv::Mat,cv::Mat> split(cv::Mat f) { return {f, f}; }
|
||||||
|
|
||||||
|
// TBB — multifunction_node + explicit try_put per port
|
||||||
|
tbb::flow::multifunction_node<cv::Mat, std::tuple<cv::Mat,cv::Mat>> 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<split>(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<In,Out>` + explicit types |
|
||||||
|
| Multi-output | `return std::tuple<A,B>` | `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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -254,4 +575,6 @@ python/
|
|||||||
kpn_python.cpp — nanobind module entry point
|
kpn_python.cpp — nanobind module entry point
|
||||||
examples/
|
examples/
|
||||||
01_hello_pipeline/ … 09_opencv_cellshade/
|
01_hello_pipeline/ … 09_opencv_cellshade/
|
||||||
|
scripts/
|
||||||
|
render_readme.py — regenerates README.md from README.md.in
|
||||||
```
|
```
|
||||||
|
|||||||
402
README.md.in
Normal file
402
README.md.in
Normal file
@ -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 <kpn/kpn.hpp>
|
||||||
|
using namespace kpn;
|
||||||
|
```
|
||||||
|
|
||||||
|
Source, transform, and sink — from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp):
|
||||||
|
|
||||||
|
<!-- @snippet examples/01_hello_pipeline/main.cpp basic_node_fns -->
|
||||||
|
|
||||||
|
Multi-output node returning a tuple — from [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp):
|
||||||
|
|
||||||
|
<!-- @snippet examples/03_multi_output/main.cpp multi_output_fn -->
|
||||||
|
|
||||||
|
### Creating Nodes
|
||||||
|
|
||||||
|
**Index-only ports** (from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp)):
|
||||||
|
|
||||||
|
<!-- @snippet examples/01_hello_pipeline/main.cpp index_only_nodes -->
|
||||||
|
|
||||||
|
**Named ports** (from [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp)):
|
||||||
|
|
||||||
|
<!-- @snippet examples/02_named_ports/main.cpp named_port_creation -->
|
||||||
|
|
||||||
|
**Multi-named output source** (from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp)):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto src = make_node<capture>(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):
|
||||||
|
|
||||||
|
<!-- @snippet examples/02_named_ports/main.cpp named_port_network -->
|
||||||
|
|
||||||
|
`.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<const T>` 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)):
|
||||||
|
|
||||||
|
<!-- @snippet examples/04_storage_policy/main.cpp storage_policy_spec -->
|
||||||
|
|
||||||
|
### Diagnostics & Error Handling
|
||||||
|
|
||||||
|
Custom diagnostics handler — fires on the watchdog interval (from [`examples/05_error_handling/main.cpp`](examples/05_error_handling/main.cpp)):
|
||||||
|
|
||||||
|
<!-- @snippet examples/05_error_handling/main.cpp diagnostics_handler -->
|
||||||
|
|
||||||
|
### 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<std::size_t N>
|
||||||
|
fixed_string(const char (&)[N]) -> fixed_string<N>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`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):
|
||||||
|
|
||||||
|
<!-- @snippet examples/09_opencv_cellshade/main.cpp display_node -->
|
||||||
|
|
||||||
|
Wire it into the network and drive it from the main thread:
|
||||||
|
|
||||||
|
<!-- @snippet examples/09_opencv_cellshade/main.cpp main_thread_step -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
<!-- @snippet examples/09_opencv_cellshade/main.cpp capture_fn -->
|
||||||
|
|
||||||
|
**Full network wiring:**
|
||||||
|
|
||||||
|
<!-- @snippet examples/09_opencv_cellshade/main.cpp opencv_network -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
<!-- @snippet examples/03_multi_output/main.cpp fanout_network -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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<int,int>` (serial concurrency) with
|
||||||
|
`tbb::flow::broadcast_node<int>` 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<const T>` — 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<int,int> n(g, tbb::flow::serial, [](int x){ return x*2; });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-output node:**
|
||||||
|
```cpp
|
||||||
|
// KPN — return a tuple
|
||||||
|
std::tuple<cv::Mat,cv::Mat> split(cv::Mat f) { return {f, f}; }
|
||||||
|
|
||||||
|
// TBB — multifunction_node + explicit try_put per port
|
||||||
|
tbb::flow::multifunction_node<cv::Mat, std::tuple<cv::Mat,cv::Mat>> 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<split>(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<In,Out>` + explicit types |
|
||||||
|
| Multi-output | `return std::tuple<A,B>` | `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<T>, channel_storage_policy, exceptions
|
||||||
|
port.hpp — InputPort<N,I>, OutputPort<N,I>
|
||||||
|
node.hpp — Node<Func,in<...>,out<...>>, make_node, INode
|
||||||
|
network.hpp — Network (builder, cycle detection, watchdog)
|
||||||
|
variant_node.hpp — VariantNode, PythonConverter<T>, 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
|
||||||
|
```
|
||||||
14
benchmarks/CMakeLists.txt
Normal file
14
benchmarks/CMakeLists.txt
Normal file
@ -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()
|
||||||
526
benchmarks/bench_pipeline.cpp
Normal file
526
benchmarks/bench_pipeline.cpp
Normal file
@ -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<W>: 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 <kpn/kpn.hpp>
|
||||||
|
|
||||||
|
#ifdef KPN_BENCH_TBB
|
||||||
|
#include <oneapi/tbb/flow_graph.h>
|
||||||
|
namespace tbb_flow = oneapi::tbb::flow;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace kpn;
|
||||||
|
using namespace std::chrono_literals;
|
||||||
|
using sclock = std::chrono::steady_clock;
|
||||||
|
|
||||||
|
// ── configurable work ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static std::atomic<int> 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<chain_fn, in<>, out<>>;
|
||||||
|
using PoolChainNode = PoolNode<chain_fn, in<>, out<>>;
|
||||||
|
|
||||||
|
// ── push helper: yield-spin on overflow (no artificial sleep latency) ─────────
|
||||||
|
|
||||||
|
static void push_retry(Channel<int>& 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<std::shared_ptr<Channel<int>>> chs;
|
||||||
|
for (int i = 0; i <= depth; ++i)
|
||||||
|
chs.push_back(std::make_shared<Channel<int>>(CAP));
|
||||||
|
|
||||||
|
std::vector<std::unique_ptr<ChainNode>> nodes;
|
||||||
|
for (int i = 0; i < depth; ++i) {
|
||||||
|
nodes.push_back(std::make_unique<ChainNode>(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<sclock::time_point> 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<double>(
|
||||||
|
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<double>(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<ThreadPool>(pool_threads);
|
||||||
|
|
||||||
|
std::vector<std::shared_ptr<Channel<int>>> chs;
|
||||||
|
for (int i = 0; i <= depth; ++i)
|
||||||
|
chs.push_back(std::make_shared<Channel<int>>(CAP));
|
||||||
|
|
||||||
|
std::vector<std::unique_ptr<PoolChainNode>> nodes;
|
||||||
|
for (int i = 0; i < depth; ++i) {
|
||||||
|
nodes.push_back(std::make_unique<PoolChainNode>(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<sclock::time_point> 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<double>(
|
||||||
|
t1.load(std::memory_order_acquire) - t0).count();
|
||||||
|
double pipeline_us = static_cast<double>(work_us) * (N + depth - 1);
|
||||||
|
double wus = (elapsed * 1e6 - pipeline_us) / N;
|
||||||
|
return {"chain", depth, work_us, pool_threads, N / elapsed, wus};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── wide (fanout<W>) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<std::size_t W>
|
||||||
|
static Result bench_wide(int work_us) {
|
||||||
|
const int N = items_for(work_us);
|
||||||
|
const int CAP = N;
|
||||||
|
|
||||||
|
auto src_ch = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto fan = std::make_unique<FanoutNode<int, W>>(CAP);
|
||||||
|
fan->template set_input_channel<0>(src_ch);
|
||||||
|
|
||||||
|
std::array<std::unique_ptr<ChainNode>, W> nodes;
|
||||||
|
std::array<std::shared_ptr<Channel<int>>, W> sink_chs;
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < W; ++i) {
|
||||||
|
nodes[i] = std::make_unique<ChainNode>(CAP);
|
||||||
|
sink_chs[i] = std::make_shared<Channel<int>>(CAP);
|
||||||
|
nodes[i]->template set_output_channel<0>(sink_chs[i].get());
|
||||||
|
}
|
||||||
|
|
||||||
|
[&]<std::size_t... Is>(std::index_sequence<Is...>) {
|
||||||
|
(fan->template set_output_channel<Is>(
|
||||||
|
&nodes[Is]->template input_channel<0>()), ...);
|
||||||
|
}(std::make_index_sequence<W>{});
|
||||||
|
|
||||||
|
fan->start();
|
||||||
|
for (auto& n : nodes) n->start();
|
||||||
|
|
||||||
|
std::array<std::thread, W> readers;
|
||||||
|
std::atomic<sclock::time_point> t1;
|
||||||
|
std::atomic<int> 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<int>(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<double>(
|
||||||
|
t1.load(std::memory_order_acquire) - t0).count();
|
||||||
|
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||||
|
return {"wide", static_cast<int>(W), work_us, 0, N / elapsed, wus};
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t W>
|
||||||
|
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<ThreadPool>(pool_threads);
|
||||||
|
auto src_ch = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto fan = std::make_unique<FanoutNode<int, W>>(CAP);
|
||||||
|
fan->template set_input_channel<0>(src_ch);
|
||||||
|
|
||||||
|
std::array<std::unique_ptr<PoolChainNode>, W> nodes;
|
||||||
|
std::array<std::shared_ptr<Channel<int>>, W> sink_chs;
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < W; ++i) {
|
||||||
|
nodes[i] = std::make_unique<PoolChainNode>(pool, CAP);
|
||||||
|
sink_chs[i] = std::make_shared<Channel<int>>(CAP);
|
||||||
|
nodes[i]->template set_output_channel<0>(sink_chs[i].get());
|
||||||
|
}
|
||||||
|
|
||||||
|
[&]<std::size_t... Is>(std::index_sequence<Is...>) {
|
||||||
|
(fan->template set_output_channel<Is>(
|
||||||
|
&nodes[Is]->template input_channel<0>()), ...);
|
||||||
|
}(std::make_index_sequence<W>{});
|
||||||
|
|
||||||
|
fan->start();
|
||||||
|
pool->start();
|
||||||
|
for (auto& n : nodes) n->start();
|
||||||
|
|
||||||
|
std::array<std::thread, W> readers;
|
||||||
|
std::atomic<sclock::time_point> t1;
|
||||||
|
std::atomic<int> 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<int>(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<double>(
|
||||||
|
t1.load(std::memory_order_acquire) - t0).count();
|
||||||
|
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||||
|
return {"wide", static_cast<int>(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<Channel<int>>(CAP);
|
||||||
|
auto fan = std::make_unique<FanoutNode<int, 2>>(CAP);
|
||||||
|
fan->template set_input_channel<0>(src_ch);
|
||||||
|
|
||||||
|
auto nL = std::make_unique<ChainNode>(CAP);
|
||||||
|
auto nR = std::make_unique<ChainNode>(CAP);
|
||||||
|
auto nL2 = std::make_unique<ChainNode>(CAP);
|
||||||
|
auto nR2 = std::make_unique<ChainNode>(CAP);
|
||||||
|
auto chL = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto chR = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto snkL = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto snkR = std::make_shared<Channel<int>>(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<sclock::time_point> t1;
|
||||||
|
std::atomic<int> done{0};
|
||||||
|
auto make_reader = [&](Channel<int>& 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<double>(
|
||||||
|
t1.load(std::memory_order_acquire) - t0).count();
|
||||||
|
double wus = (elapsed * 1e6) / N - static_cast<double>(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<ThreadPool>(pool_threads);
|
||||||
|
auto src_ch = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto fan = std::make_unique<FanoutNode<int, 2>>(CAP);
|
||||||
|
fan->template set_input_channel<0>(src_ch);
|
||||||
|
|
||||||
|
auto nL = std::make_unique<PoolChainNode>(pool, CAP);
|
||||||
|
auto nR = std::make_unique<PoolChainNode>(pool, CAP);
|
||||||
|
auto nL2 = std::make_unique<PoolChainNode>(pool, CAP);
|
||||||
|
auto nR2 = std::make_unique<PoolChainNode>(pool, CAP);
|
||||||
|
auto chL = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto chR = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto snkL = std::make_shared<Channel<int>>(CAP);
|
||||||
|
auto snkR = std::make_shared<Channel<int>>(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<sclock::time_point> t1;
|
||||||
|
std::atomic<int> done{0};
|
||||||
|
auto make_reader = [&](Channel<int>& 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<double>(
|
||||||
|
t1.load(std::memory_order_acquire) - t0).count();
|
||||||
|
double wus = (elapsed * 1e6) / N - static_cast<double>(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<int, int>;
|
||||||
|
std::vector<std::unique_ptr<FN>> nodes;
|
||||||
|
nodes.reserve(depth);
|
||||||
|
for (int i = 0; i < depth; ++i)
|
||||||
|
nodes.push_back(std::make_unique<FN>(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<double>(t1 - t0).count();
|
||||||
|
double pipeline_us = static_cast<double>(work_us) * (N + depth - 1);
|
||||||
|
double wus = (elapsed * 1e6 - pipeline_us) / N;
|
||||||
|
return {"chain_tbb", depth, work_us, -1, N / elapsed, wus};
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t W>
|
||||||
|
static Result bench_wide_tbb(int work_us) {
|
||||||
|
const int N = items_for(work_us);
|
||||||
|
|
||||||
|
tbb_flow::graph g;
|
||||||
|
tbb_flow::broadcast_node<int> fan(g);
|
||||||
|
using FN = tbb_flow::function_node<int, int>;
|
||||||
|
std::array<std::unique_ptr<FN>, W> nodes;
|
||||||
|
for (auto& n : nodes) {
|
||||||
|
n = std::make_unique<FN>(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<double>(t1 - t0).count();
|
||||||
|
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||||
|
return {"wide_tbb", static_cast<int>(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<int> fan(g);
|
||||||
|
using FN = tbb_flow::function_node<int, int>;
|
||||||
|
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<double>(t1 - t0).count();
|
||||||
|
double wus = (elapsed * 1e6) / N - static_cast<double>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,16 +7,20 @@
|
|||||||
//
|
//
|
||||||
// [produce] --int--> [double_it] --int--> [print_it]
|
// [produce] --int--> [double_it] --int--> [print_it]
|
||||||
|
|
||||||
|
// [snippet: basic_node_fns]
|
||||||
static int produce() { return 42; }
|
static int produce() { return 42; }
|
||||||
static int double_it(int x) { return x * 2; }
|
static int double_it(int x) { return x * 2; }
|
||||||
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
|
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
|
||||||
|
// [/snippet: basic_node_fns]
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
using namespace kpn;
|
using namespace kpn;
|
||||||
|
|
||||||
|
// [snippet: index_only_nodes]
|
||||||
auto src = make_node<produce>(5);
|
auto src = make_node<produce>(5);
|
||||||
auto dbl = make_node<double_it>(5);
|
auto dbl = make_node<double_it>(5);
|
||||||
auto sink = make_node<print_it>(5);
|
auto sink = make_node<print_it>(5);
|
||||||
|
// [/snippet: index_only_nodes]
|
||||||
|
|
||||||
// Wire channels
|
// Wire channels
|
||||||
auto& dbl_in = dbl.input_channel<0>();
|
auto& dbl_in = dbl.input_channel<0>();
|
||||||
@ -24,6 +28,7 @@ int main() {
|
|||||||
src.set_output_channel<0>(&dbl_in);
|
src.set_output_channel<0>(&dbl_in);
|
||||||
dbl.set_output_channel<0>(&sink_in);
|
dbl.set_output_channel<0>(&sink_in);
|
||||||
|
|
||||||
|
// [snippet: network_build]
|
||||||
Network net;
|
Network net;
|
||||||
net.add("src", src)
|
net.add("src", src)
|
||||||
.add("dbl", dbl)
|
.add("dbl", dbl)
|
||||||
@ -35,4 +40,5 @@ int main() {
|
|||||||
net.start();
|
net.start();
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
net.stop();
|
net.stop();
|
||||||
|
// [/snippet: network_build]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,17 +54,18 @@ static void report(int count, std::vector<std::string> words) {
|
|||||||
int main() {
|
int main() {
|
||||||
using namespace kpn;
|
using namespace kpn;
|
||||||
|
|
||||||
|
// [snippet: named_port_creation]
|
||||||
// tokenise: no inputs, one named output "words"
|
// tokenise: no inputs, one named output "words"
|
||||||
auto tok = make_node<tokenise>(out<"words">{}, 4);
|
auto tok = make_node<tokenise>(out<"words">{}, 4);
|
||||||
|
|
||||||
// count_words: named input "words", named outputs "count" and "words"
|
// count_words: named input "words", named outputs "count" and "words"
|
||||||
auto cnt = make_node<count_words>(in<"words">{}, out<"count", "words">{}, 4);
|
auto cnt = make_node<count_words>(in<"words">{}, out<"count", "words">{}, 4);
|
||||||
|
|
||||||
// report: two named inputs — note the function takes (int, vector<string>)
|
// report: two named inputs
|
||||||
// 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
|
|
||||||
auto snk = make_node<report>(in<"count", "words">{}, 4);
|
auto snk = make_node<report>(in<"count", "words">{}, 4);
|
||||||
|
// [/snippet: named_port_creation]
|
||||||
|
|
||||||
|
// [snippet: named_port_network]
|
||||||
Network net;
|
Network net;
|
||||||
net.add("tok", tok)
|
net.add("tok", tok)
|
||||||
.add("cnt", cnt)
|
.add("cnt", cnt)
|
||||||
@ -77,4 +78,5 @@ int main() {
|
|||||||
net.start();
|
net.start();
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||||
net.stop();
|
net.stop();
|
||||||
|
// [/snippet: named_port_network]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ static std::string generate() {
|
|||||||
return pairs[gen_index++ % 5];
|
return pairs[gen_index++ % 5];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [snippet: multi_output_fn]
|
||||||
// Multi-output: returns (key, value) as a tuple — KPN++ routes each element
|
// Multi-output: returns (key, value) as a tuple — KPN++ routes each element
|
||||||
// to its own output port automatically.
|
// to its own output port automatically.
|
||||||
static std::tuple<std::string, std::string> parse(std::string kv) {
|
static std::tuple<std::string, std::string> parse(std::string kv) {
|
||||||
@ -40,6 +41,7 @@ static std::tuple<std::string, std::string> parse(std::string kv) {
|
|||||||
if (sep == std::string::npos) return {kv, ""};
|
if (sep == std::string::npos) return {kv, ""};
|
||||||
return {kv.substr(0, sep), kv.substr(sep + 1)};
|
return {kv.substr(0, sep), kv.substr(sep + 1)};
|
||||||
}
|
}
|
||||||
|
// [/snippet: multi_output_fn]
|
||||||
|
|
||||||
static void print_key(std::string key) {
|
static void print_key(std::string key) {
|
||||||
std::cout << "KEY → " << key << '\n';
|
std::cout << "KEY → " << key << '\n';
|
||||||
@ -54,6 +56,7 @@ static void print_value(std::string value) {
|
|||||||
int main() {
|
int main() {
|
||||||
using namespace kpn;
|
using namespace kpn;
|
||||||
|
|
||||||
|
// [snippet: fanout_network]
|
||||||
auto gen = make_node<generate>(out<"kv">{}, 4);
|
auto gen = make_node<generate>(out<"kv">{}, 4);
|
||||||
auto par = make_node<parse> (in<"kv">{}, out<"key", "value">{}, 4);
|
auto par = make_node<parse> (in<"kv">{}, out<"key", "value">{}, 4);
|
||||||
auto keys = make_node<print_key> (in<"key">{}, 4);
|
auto keys = make_node<print_key> (in<"key">{}, 4);
|
||||||
@ -72,4 +75,5 @@ int main() {
|
|||||||
net.start();
|
net.start();
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(600));
|
std::this_thread::sleep_for(std::chrono::milliseconds(600));
|
||||||
net.stop();
|
net.stop();
|
||||||
|
// [/snippet: fanout_network]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,12 +34,14 @@ struct Tag {
|
|||||||
int value = 0;
|
int value = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [snippet: storage_policy_spec]
|
||||||
// Override: store Tag by value despite being a struct
|
// Override: store Tag by value despite being a struct
|
||||||
// (it's trivially copyable and small — this just makes the policy explicit)
|
// (it's trivially copyable and small — this just makes the policy explicit)
|
||||||
template<>
|
template<>
|
||||||
struct kpn::channel_storage_policy<Tag> {
|
struct kpn::channel_storage_policy<Tag> {
|
||||||
static constexpr bool by_value = true;
|
static constexpr bool by_value = true;
|
||||||
};
|
};
|
||||||
|
// [/snippet: storage_policy_spec]
|
||||||
|
|
||||||
// ── Node functions ────────────────────────────────────────────────────────────
|
// ── Node functions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,7 @@ int main() {
|
|||||||
|
|
||||||
Network net;
|
Network net;
|
||||||
|
|
||||||
|
// [snippet: diagnostics_handler]
|
||||||
// Custom diagnostics handler — fires on the watchdog interval.
|
// Custom diagnostics handler — fires on the watchdog interval.
|
||||||
// Print a concise one-liner rather than the full table.
|
// Print a concise one-liner rather than the full table.
|
||||||
net.set_diagnostics_handler([](const std::vector<NodeSnapshot>& nodes,
|
net.set_diagnostics_handler([](const std::vector<NodeSnapshot>& nodes,
|
||||||
@ -57,6 +58,7 @@ int main() {
|
|||||||
<< "overflows=" << c.overflows;
|
<< "overflows=" << c.overflows;
|
||||||
std::cout << '\n';
|
std::cout << '\n';
|
||||||
});
|
});
|
||||||
|
// [/snippet: diagnostics_handler]
|
||||||
|
|
||||||
net.set_watchdog_interval(std::chrono::milliseconds(200));
|
net.set_watchdog_interval(std::chrono::milliseconds(200));
|
||||||
|
|
||||||
|
|||||||
101
examples/09_opencv_cellshade/example_hybrid.py
Normal file
101
examples/09_opencv_cellshade/example_hybrid.py
Normal file
@ -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
|
||||||
177
examples/09_opencv_cellshade/kpn_opencv.cpp
Normal file
177
examples/09_opencv_cellshade/kpn_opencv.cpp
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
#define KPN_BUILD_PYTHON
|
||||||
|
#include <kpn/python/auto_bind.hpp>
|
||||||
|
|
||||||
|
#include <nanobind/ndarray.h>
|
||||||
|
|
||||||
|
#include <opencv2/core.hpp>
|
||||||
|
#include <opencv2/imgproc.hpp>
|
||||||
|
#include <opencv2/videoio.hpp>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
#include <thread>
|
||||||
|
#include <tuple>
|
||||||
|
|
||||||
|
namespace nb = nanobind;
|
||||||
|
using namespace kpn;
|
||||||
|
using namespace kpn::python;
|
||||||
|
|
||||||
|
// ── PythonConverter<cv::Mat> ──────────────────────────────────────────────────
|
||||||
|
// 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<cv::Mat> {
|
||||||
|
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<const char*>(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<std::vector<int>>(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<nb::ndarray<nb::numpy, uint8_t>>(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<cv::Mat, cv::Mat> 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<uchar>(i) = cv::saturate_cast<uchar>(
|
||||||
|
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<cv::Mat, cv::Mat> 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<cv::Mat> — every node uses only cv::Mat.
|
||||||
|
|
||||||
|
using CvNodes = NodeRegistry<
|
||||||
|
Entry<capture, "capture">,
|
||||||
|
Entry<to_gray, "to_gray">,
|
||||||
|
Entry<edges_fn, "edges">,
|
||||||
|
Entry<quantise, "quantise">,
|
||||||
|
Entry<composite, "composite">
|
||||||
|
>;
|
||||||
|
|
||||||
|
// ── 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_<name>() factories.
|
||||||
|
// Network.add_node(name, callable, inputs=["mat"], outputs=["mat"])
|
||||||
|
// accepts Python callables that receive/return numpy uint8 arrays.
|
||||||
|
bind_network<CvNodes>(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.
|
||||||
|
}
|
||||||
@ -8,6 +8,12 @@
|
|||||||
#include <thread>
|
#include <thread>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
|
||||||
|
// Teach KPN how many bytes a cv::Mat actually carries (header + pixel data).
|
||||||
|
template<>
|
||||||
|
struct kpn::ChannelDataSize<cv::Mat> {
|
||||||
|
static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
|
||||||
|
};
|
||||||
|
|
||||||
// ── Cell-shading pipeline ─────────────────────────────────────────────────────
|
// ── Cell-shading pipeline ─────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// [capture] --"colour"--> [quantise] ──────────────────────────┐
|
// [capture] --"colour"--> [quantise] ──────────────────────────┐
|
||||||
@ -32,6 +38,7 @@ static cv::Mat make_gradient(int W, int H) {
|
|||||||
|
|
||||||
// ── Pipeline functions ────────────────────────────────────────────────────────
|
// ── Pipeline functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// [snippet: capture_fn]
|
||||||
static std::tuple<cv::Mat, cv::Mat> capture() {
|
static std::tuple<cv::Mat, cv::Mat> capture() {
|
||||||
constexpr int W = 640, H = 480;
|
constexpr int W = 640, H = 480;
|
||||||
static cv::VideoCapture cap;
|
static cv::VideoCapture cap;
|
||||||
@ -68,6 +75,7 @@ static std::tuple<cv::Mat, cv::Mat> capture() {
|
|||||||
}
|
}
|
||||||
return {frame.clone(), frame.clone()};
|
return {frame.clone(), frame.clone()};
|
||||||
}
|
}
|
||||||
|
// [/snippet: capture_fn]
|
||||||
|
|
||||||
static cv::Mat to_gray(cv::Mat bgr) {
|
static cv::Mat to_gray(cv::Mat bgr) {
|
||||||
cv::Mat gray;
|
cv::Mat gray;
|
||||||
@ -112,6 +120,7 @@ static std::tuple<cv::Mat, cv::Mat> composite(cv::Mat edge_mask, cv::Mat colour)
|
|||||||
// The constructor opens both windows on the main thread (Wayland requirement).
|
// The constructor opens both windows on the main thread (Wayland requirement).
|
||||||
// operator() is called by step() whenever both channels have a frame ready.
|
// operator() is called by step() whenever both channels have a frame ready.
|
||||||
|
|
||||||
|
// [snippet: display_node]
|
||||||
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
|
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
|
||||||
kpn::in<"composite", "edges">,
|
kpn::in<"composite", "edges">,
|
||||||
cv::Mat, cv::Mat> {
|
cv::Mat, cv::Mat> {
|
||||||
@ -141,12 +150,14 @@ private:
|
|||||||
catch (const cv::Exception&) { return false; }
|
catch (const cv::Exception&) { return false; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// [/snippet: display_node]
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
using namespace kpn;
|
using namespace kpn;
|
||||||
|
|
||||||
|
// [snippet: opencv_network]
|
||||||
auto src = make_node<capture> (out<"colour","grey">{}, 8);
|
auto src = make_node<capture> (out<"colour","grey">{}, 8);
|
||||||
auto gray_node = make_node<to_gray> (in<"bgr">{}, out<"gray">{}, 8);
|
auto gray_node = make_node<to_gray> (in<"bgr">{}, out<"gray">{}, 8);
|
||||||
auto edge_node = make_node<edges_fn> (in<"gray">{}, out<"edges">{}, 8);
|
auto edge_node = make_node<edges_fn> (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<"result">(), "display", disp.template input<"composite">())
|
||||||
.connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">())
|
.connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">())
|
||||||
.build();
|
.build();
|
||||||
|
// [/snippet: opencv_network]
|
||||||
|
|
||||||
net.set_watchdog_interval(std::chrono::milliseconds(5000));
|
net.set_watchdog_interval(std::chrono::milliseconds(5000));
|
||||||
|
#ifdef KPN_WEB_DEBUG
|
||||||
net.set_web_debug_port(9090);
|
net.set_web_debug_port(9090);
|
||||||
|
#endif
|
||||||
|
|
||||||
std::cout << "Cell-shading pipeline running. Press 'q' to stop.\n";
|
std::cout << "Cell-shading pipeline running. Press 'q' to stop.\n";
|
||||||
std::cout << "Web debug UI: http://localhost:9090\n";
|
std::cout << "Web debug UI: http://localhost:9090\n";
|
||||||
|
|
||||||
|
// [snippet: main_thread_step]
|
||||||
net.start();
|
net.start();
|
||||||
|
|
||||||
// Main thread drives display — imshow/waitKey stay on the GUI thread.
|
// 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
|
cv::waitKey(8); // yield event loop when no frame ready
|
||||||
|
|
||||||
net.stop();
|
net.stop();
|
||||||
|
// [/snippet: main_thread_step]
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,14 +21,18 @@ if(KPN_WEB_DEBUG)
|
|||||||
target_link_libraries(14_debug_hub PRIVATE kpn)
|
target_link_libraries(14_debug_hub PRIVATE kpn)
|
||||||
kpn_target_enable_web_debug(14_debug_hub)
|
kpn_target_enable_web_debug(14_debug_hub)
|
||||||
endif()
|
endif()
|
||||||
# 07 and 08 require the Python bindings — only add if built
|
# 07 and 08 are Python scripts — no compiled target needed.
|
||||||
if(KPN_BUILD_PYTHON)
|
|
||||||
# These are Python scripts, not compiled targets — installed alongside kpn_python
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# 09 requires OpenCV — only build if found
|
# 09 requires OpenCV — only build if found
|
||||||
find_package(OpenCV QUIET COMPONENTS core imgproc highgui videoio)
|
find_package(OpenCV QUIET COMPONENTS core imgproc highgui videoio)
|
||||||
if(OpenCV_FOUND)
|
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)
|
add_executable(09_opencv_cellshade 09_opencv_cellshade/main.cpp)
|
||||||
target_link_libraries(09_opencv_cellshade PRIVATE kpn ${OpenCV_LIBS})
|
target_link_libraries(09_opencv_cellshade PRIVATE kpn ${OpenCV_LIBS})
|
||||||
|
|
||||||
|
|||||||
301
include/kpn/branch.hpp
Normal file
301
include/kpn/branch.hpp
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "channel.hpp"
|
||||||
|
#include "diagnostics.hpp"
|
||||||
|
#include "inode.hpp"
|
||||||
|
#include "port.hpp"
|
||||||
|
#include "traits.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace kpn {
|
||||||
|
|
||||||
|
// ── RouterNode ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Reads one item and pushes it to exactly one of N output channels, chosen by
|
||||||
|
// selector(item). If selector returns >= N the item is silently dropped.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// auto router = make_router<Image, 3>(
|
||||||
|
// [](const Image& img) -> std::size_t { return img.stream_id % 3; });
|
||||||
|
// net.connect("src", src.output<0>(), "router", router.input<0>())
|
||||||
|
// .connect("router", router.output<0>(), "nodeA", nodeA.input<0>())
|
||||||
|
// .connect("router", router.output<1>(), "nodeB", nodeB.input<0>())
|
||||||
|
// .connect("router", router.output<2>(), "nodeC", nodeC.input<0>());
|
||||||
|
|
||||||
|
template<typename T, std::size_t N, std::size_t Id = 0>
|
||||||
|
class RouterNode : public INode {
|
||||||
|
public:
|
||||||
|
using Selector = std::function<std::size_t(const T&)>;
|
||||||
|
using args_tuple = std::tuple<T>;
|
||||||
|
using return_tuple = repeat_tuple_t<T, N>;
|
||||||
|
using return_raw = return_tuple;
|
||||||
|
|
||||||
|
static constexpr std::size_t input_count = 1;
|
||||||
|
static constexpr std::size_t output_count = N;
|
||||||
|
static constexpr std::size_t unique_tag = Id;
|
||||||
|
static constexpr bool is_router_node = true;
|
||||||
|
|
||||||
|
explicit RouterNode(Selector sel, std::size_t fifo_capacity = 5)
|
||||||
|
: selector_(std::move(sel))
|
||||||
|
, fifo_capacity_(fifo_capacity)
|
||||||
|
{
|
||||||
|
input_ch_ = std::make_shared<Channel<T>>(fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
~RouterNode() override { stop(); }
|
||||||
|
|
||||||
|
// ── INode ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void start() override {
|
||||||
|
input_ch_->enable();
|
||||||
|
stop_flag_.store(false, std::memory_order_relaxed);
|
||||||
|
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() override {
|
||||||
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
|
input_ch_->disable();
|
||||||
|
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool running() const override {
|
||||||
|
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_name(std::string name) override { name_ = std::move(name); }
|
||||||
|
|
||||||
|
const NodeStats& stats() const override { return stats_; }
|
||||||
|
|
||||||
|
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||||
|
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||||
|
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double total_ms = exec_ms + blocked_ms;
|
||||||
|
return {name, frames, exec_ms,
|
||||||
|
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
blocked_ms,
|
||||||
|
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||||
|
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port access ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<std::size_t I = 0>
|
||||||
|
InputPort<RouterNode, I> input() {
|
||||||
|
static_assert(I == 0, "RouterNode has exactly one input");
|
||||||
|
return {*this};
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
OutputPort<RouterNode, I> output() {
|
||||||
|
static_assert(I < N, "RouterNode output index out of range");
|
||||||
|
return {*this};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal channel accessors (called by Network::connect) ───────────────
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
Channel<T>& input_channel() {
|
||||||
|
static_assert(I == 0);
|
||||||
|
return *input_ch_;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_input_channel(std::shared_ptr<Channel<T>> ch) {
|
||||||
|
static_assert(I == 0);
|
||||||
|
input_ch_ = std::move(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_output_channel(Channel<T>* ch) {
|
||||||
|
static_assert(I < N);
|
||||||
|
out_channels_[I] = ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void run_loop() {
|
||||||
|
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||||
|
try {
|
||||||
|
auto t0 = clock_t::now();
|
||||||
|
T val = input_ch_->pop();
|
||||||
|
auto t1 = clock_t::now();
|
||||||
|
auto cpu0 = NodeStats::cpu_now();
|
||||||
|
|
||||||
|
std::size_t idx = selector_(val);
|
||||||
|
if (idx < N && out_channels_[idx]) {
|
||||||
|
try { out_channels_[idx]->push(val); }
|
||||||
|
catch (const ChannelOverflowError&) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto cpu1 = NodeStats::cpu_now();
|
||||||
|
auto t2 = clock_t::now();
|
||||||
|
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||||
|
} catch (const ChannelClosedError&) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string name_;
|
||||||
|
std::size_t fifo_capacity_;
|
||||||
|
Selector selector_;
|
||||||
|
std::shared_ptr<Channel<T>> input_ch_;
|
||||||
|
std::array<Channel<T>*, N> out_channels_{};
|
||||||
|
std::atomic<bool> stop_flag_{false};
|
||||||
|
std::jthread thread_;
|
||||||
|
NodeStats stats_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── FilterNode ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Reads one item and pushes it downstream only when pred(item) is true.
|
||||||
|
// Dropped items are not counted as processed frames.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// auto filt = make_filter<Frame>([](const Frame& f) { return f.valid; });
|
||||||
|
// net.connect("src", src.output<0>(), "filt", filt.input<0>())
|
||||||
|
// .connect("filt", filt.output<0>(), "dst", dst.input<0>());
|
||||||
|
|
||||||
|
template<typename T, std::size_t Id = 0>
|
||||||
|
class FilterNode : public INode {
|
||||||
|
public:
|
||||||
|
using Predicate = std::function<bool(const T&)>;
|
||||||
|
using args_tuple = std::tuple<T>;
|
||||||
|
using return_tuple = std::tuple<T>;
|
||||||
|
using return_raw = return_tuple;
|
||||||
|
|
||||||
|
static constexpr std::size_t input_count = 1;
|
||||||
|
static constexpr std::size_t output_count = 1;
|
||||||
|
static constexpr std::size_t unique_tag = Id;
|
||||||
|
static constexpr bool is_filter_node = true;
|
||||||
|
|
||||||
|
explicit FilterNode(Predicate pred, std::size_t fifo_capacity = 5)
|
||||||
|
: pred_(std::move(pred))
|
||||||
|
, fifo_capacity_(fifo_capacity)
|
||||||
|
{
|
||||||
|
input_ch_ = std::make_shared<Channel<T>>(fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
~FilterNode() override { stop(); }
|
||||||
|
|
||||||
|
// ── INode ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void start() override {
|
||||||
|
input_ch_->enable();
|
||||||
|
stop_flag_.store(false, std::memory_order_relaxed);
|
||||||
|
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() override {
|
||||||
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
|
input_ch_->disable();
|
||||||
|
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool running() const override {
|
||||||
|
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_name(std::string name) override { name_ = std::move(name); }
|
||||||
|
|
||||||
|
const NodeStats& stats() const override { return stats_; }
|
||||||
|
|
||||||
|
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||||
|
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||||
|
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double total_ms = exec_ms + blocked_ms;
|
||||||
|
return {name, frames, exec_ms,
|
||||||
|
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
blocked_ms,
|
||||||
|
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||||
|
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port access ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<std::size_t I = 0>
|
||||||
|
InputPort<FilterNode, I> input() {
|
||||||
|
static_assert(I == 0, "FilterNode has exactly one input");
|
||||||
|
return {*this};
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I = 0>
|
||||||
|
OutputPort<FilterNode, I> output() {
|
||||||
|
static_assert(I == 0, "FilterNode has exactly one output");
|
||||||
|
return {*this};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal channel accessors (called by Network::connect) ───────────────
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
Channel<T>& input_channel() {
|
||||||
|
static_assert(I == 0);
|
||||||
|
return *input_ch_;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_input_channel(std::shared_ptr<Channel<T>> ch) {
|
||||||
|
static_assert(I == 0);
|
||||||
|
input_ch_ = std::move(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_output_channel(Channel<T>* ch) {
|
||||||
|
static_assert(I == 0);
|
||||||
|
out_ch_ = ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void run_loop() {
|
||||||
|
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||||
|
try {
|
||||||
|
auto t0 = clock_t::now();
|
||||||
|
T val = input_ch_->pop();
|
||||||
|
auto t1 = clock_t::now();
|
||||||
|
auto cpu0 = NodeStats::cpu_now();
|
||||||
|
|
||||||
|
if (pred_(val) && out_ch_) {
|
||||||
|
try { out_ch_->push(val); }
|
||||||
|
catch (const ChannelOverflowError&) {}
|
||||||
|
auto cpu1 = NodeStats::cpu_now();
|
||||||
|
auto t2 = clock_t::now();
|
||||||
|
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||||
|
}
|
||||||
|
} catch (const ChannelClosedError&) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string name_;
|
||||||
|
std::size_t fifo_capacity_;
|
||||||
|
Predicate pred_;
|
||||||
|
std::shared_ptr<Channel<T>> input_ch_;
|
||||||
|
Channel<T>* out_ch_{nullptr};
|
||||||
|
std::atomic<bool> stop_flag_{false};
|
||||||
|
std::jthread thread_;
|
||||||
|
NodeStats stats_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Factories ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<typename T, std::size_t N>
|
||||||
|
RouterNode<T, N> make_router(std::function<std::size_t(const T&)> sel,
|
||||||
|
std::size_t capacity = 5) {
|
||||||
|
return RouterNode<T, N, 0>(std::move(sel), capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename T>
|
||||||
|
FilterNode<T> make_filter(std::function<bool(const T&)> pred,
|
||||||
|
std::size_t capacity = 5) {
|
||||||
|
return FilterNode<T, 0>(std::move(pred), capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace kpn
|
||||||
@ -2,16 +2,30 @@
|
|||||||
#include "diagnostics.hpp"
|
#include "diagnostics.hpp"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <condition_variable>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
|
||||||
#include <queue>
|
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
#include <type_traits>
|
#include <type_traits>
|
||||||
|
|
||||||
namespace kpn {
|
namespace kpn {
|
||||||
|
|
||||||
|
// ── Data size trait ───────────────────────────────────────────────────────────
|
||||||
|
// Returns the number of bytes of logical payload carried by a value.
|
||||||
|
// Defaults to sizeof(T), which is correct for PODs and fixed-size types.
|
||||||
|
// Specialize for heap-owning types (e.g. cv::Mat) to get accurate bandwidth:
|
||||||
|
//
|
||||||
|
// template<> struct kpn::ChannelDataSize<cv::Mat> {
|
||||||
|
// static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
|
||||||
|
// };
|
||||||
|
|
||||||
|
template<typename T>
|
||||||
|
struct ChannelDataSize {
|
||||||
|
static std::size_t bytes(const T&) { return sizeof(T); }
|
||||||
|
};
|
||||||
|
|
||||||
// ── Storage policy ────────────────────────────────────────────────────────────
|
// ── Storage policy ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
@ -44,67 +58,143 @@ public:
|
|||||||
ChannelClosedError() : std::runtime_error("channel closed") {}
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Channel ───────────────────────────────────────────────────────────────────
|
||||||
|
// SPSC ring buffer with atomic wait/notify and configurable spin-before-sleep.
|
||||||
|
//
|
||||||
|
// `spin_count` (constructor arg, default 200): number of pause-hint iterations
|
||||||
|
// before falling back to atomic::wait (futex). At ~20 ns/pause on x86 this is
|
||||||
|
// ~4 µs. Set to 0 to disable spinning (useful for power-constrained or
|
||||||
|
// predominantly-idle pipelines).
|
||||||
|
//
|
||||||
|
// Memory ordering contract (SPSC):
|
||||||
|
// push(): tail_.store(release) pairs with pop()'s tail_.load(acquire)
|
||||||
|
// head_.load(acquire) pairs with pop()'s head_.store(release)
|
||||||
|
// pop(): head_.store(release) pairs with push()'s head_.load(acquire)
|
||||||
|
// tail_.load(acquire) pairs with push()'s tail_.store(release)
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
class Channel {
|
class Channel {
|
||||||
public:
|
public:
|
||||||
using storage_type = channel_storage_t<T>;
|
using storage_type = channel_storage_t<T>;
|
||||||
|
|
||||||
explicit Channel(std::size_t capacity = 5) : capacity_(capacity) {}
|
explicit Channel(std::size_t capacity = 5, std::size_t spin_count = 200)
|
||||||
|
: capacity_(capacity), spin_count_(spin_count)
|
||||||
|
{
|
||||||
|
std::size_t rs = 1;
|
||||||
|
while (rs <= capacity) rs <<= 1; // smallest power-of-2 > capacity
|
||||||
|
ring_mask_ = rs - 1;
|
||||||
|
buf_ = std::make_unique<storage_type[]>(rs);
|
||||||
|
}
|
||||||
|
|
||||||
Channel(const Channel&) = delete;
|
Channel(const Channel&) = delete;
|
||||||
Channel& operator=(const Channel&) = delete;
|
Channel& operator=(const Channel&) = delete;
|
||||||
|
|
||||||
// Push a value.
|
// Push a value.
|
||||||
// - If channel is disabled (accepting_ == false): silently drop, return immediately.
|
// - If channel is disabled (accepting_ == false): silently drop.
|
||||||
// - If channel is full: throw ChannelOverflowError.
|
// - If channel is full (fill >= capacity_): throw ChannelOverflowError.
|
||||||
void push(T value) {
|
void push(T value) {
|
||||||
if (!accepting_.load(std::memory_order_relaxed)) {
|
if (!accepting_.load(std::memory_order_relaxed)) {
|
||||||
stats_.record_drop();
|
stats_.record_drop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
std::unique_lock lock(mutex_);
|
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
|
||||||
if (!accepting_.load(std::memory_order_relaxed)) {
|
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();
|
stats_.record_drop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (queue_.size() >= capacity_) {
|
if (t - h >= capacity_) {
|
||||||
stats_.record_overflow();
|
stats_.record_overflow();
|
||||||
throw ChannelOverflowError(capacity_);
|
throw ChannelOverflowError(capacity_);
|
||||||
}
|
}
|
||||||
queue_.push(make_storage(std::move(value)));
|
|
||||||
stats_.record_push(queue_.size());
|
const bool was_empty = (t == h);
|
||||||
lock.unlock();
|
buf_[t & ring_mask_] = make_storage(std::move(value));
|
||||||
cv_.notify_one();
|
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.
|
// Blocking pop. Returns when an item is available.
|
||||||
// Throws ChannelClosedError if disabled and queue is empty.
|
// Throws ChannelClosedError if the channel is disabled (regardless of fill).
|
||||||
T pop() {
|
T pop() {
|
||||||
std::unique_lock lock(mutex_);
|
for (;;) {
|
||||||
cv_.wait(lock, [this] {
|
// Snapshot wake_ BEFORE reading tail_ to prevent lost wakeups.
|
||||||
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
|
const uint32_t w = wake_.load(std::memory_order_relaxed);
|
||||||
});
|
const std::size_t h = head_.load(std::memory_order_relaxed);
|
||||||
if (queue_.empty())
|
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{};
|
throw ChannelClosedError{};
|
||||||
T value = extract(std::move(queue_.front()));
|
|
||||||
queue_.pop();
|
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();
|
stats_.record_pop();
|
||||||
return value;
|
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) {
|
bool try_pop(T& out, std::chrono::milliseconds timeout) {
|
||||||
std::unique_lock lock(mutex_);
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||||
if (!cv_.wait_for(lock, timeout, [this] {
|
for (;;) {
|
||||||
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
|
if (try_pop_now(out)) return true;
|
||||||
}))
|
if (!accepting_.load(std::memory_order_relaxed)) return false;
|
||||||
return false;
|
if (std::chrono::steady_clock::now() >= deadline) return false;
|
||||||
if (queue_.empty())
|
std::this_thread::sleep_for(std::chrono::microseconds(50));
|
||||||
return false;
|
}
|
||||||
out = extract(std::move(queue_.front()));
|
}
|
||||||
queue_.pop();
|
|
||||||
|
// 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();
|
stats_.record_pop();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -114,38 +204,44 @@ public:
|
|||||||
accepting_.store(true, std::memory_order_relaxed);
|
accepting_.store(true, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable the channel: drop all queued items, unblock any waiting pop().
|
// Disable the channel: stop accepting new pushes, unblock any waiting pop().
|
||||||
// Called by consumer node on stop(). Producer push() will silently drop after this.
|
// Items already in the ring are abandoned and freed when the Channel is destroyed.
|
||||||
void disable() {
|
void disable() {
|
||||||
accepting_.store(false, std::memory_order_relaxed);
|
accepting_.store(false, std::memory_order_release);
|
||||||
{
|
wake_.fetch_add(1, std::memory_order_release);
|
||||||
std::lock_guard lock(mutex_);
|
wake_.notify_all();
|
||||||
while (!queue_.empty()) queue_.pop();
|
|
||||||
}
|
|
||||||
cv_.notify_all();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::size_t size() const {
|
// Register a callback fired when the queue transitions empty→non-empty.
|
||||||
std::lock_guard lock(mutex_);
|
void set_push_callback(std::function<void()> cb) {
|
||||||
return queue_.size();
|
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_; }
|
std::size_t capacity() const { return capacity_; }
|
||||||
bool is_accepting() const { return accepting_.load(std::memory_order_relaxed); }
|
bool is_accepting() const { return accepting_.load(std::memory_order_relaxed); }
|
||||||
const ChannelStats& stats() const { return stats_; }
|
const ChannelStats& stats() const { return stats_; }
|
||||||
|
|
||||||
ChannelSnapshot snapshot(const std::string& name) const {
|
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 {
|
return {
|
||||||
name,
|
name,
|
||||||
capacity_,
|
capacity_,
|
||||||
queue_.size(),
|
t - h,
|
||||||
stats_.peak_fill.load(std::memory_order_relaxed),
|
stats_.peak_fill.load(std::memory_order_relaxed),
|
||||||
stats_.pushes.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_.drops.load(std::memory_order_relaxed),
|
||||||
stats_.overflows.load(std::memory_order_relaxed),
|
stats_.overflows.load(std::memory_order_relaxed),
|
||||||
stats_.pops.load(std::memory_order_relaxed),
|
stats_.pops.load(std::memory_order_relaxed),
|
||||||
sizeof(T), // payload bytes — sizeof(T) regardless of storage policy
|
sizeof(T),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,11 +261,18 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const std::size_t capacity_;
|
const std::size_t capacity_;
|
||||||
std::queue<storage_type> queue_;
|
const std::size_t spin_count_;
|
||||||
std::atomic<bool> accepting_{true};
|
std::size_t ring_mask_;
|
||||||
mutable std::mutex mutex_;
|
std::unique_ptr<storage_type[]> buf_;
|
||||||
std::condition_variable cv_;
|
std::function<void()> push_callback_;
|
||||||
ChannelStats stats_;
|
ChannelStats stats_;
|
||||||
|
|
||||||
|
// Separate cache lines: head_ is written only by the consumer;
|
||||||
|
// tail_ and wake_ are written only by the producer.
|
||||||
|
alignas(64) std::atomic<std::size_t> head_{0};
|
||||||
|
alignas(64) std::atomic<std::size_t> tail_{0};
|
||||||
|
std::atomic<uint32_t> wake_{0};
|
||||||
|
std::atomic<bool> accepting_{true};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Channel probe — type-erased snapshot accessor ─────────────────────────────
|
// ── Channel probe — type-erased snapshot accessor ─────────────────────────────
|
||||||
|
|||||||
@ -15,6 +15,7 @@ using duration_t = std::chrono::duration<double, std::milli>; // milliseconds
|
|||||||
|
|
||||||
struct ChannelStats {
|
struct ChannelStats {
|
||||||
std::atomic<uint64_t> pushes{0};
|
std::atomic<uint64_t> pushes{0};
|
||||||
|
std::atomic<uint64_t> bytes_pushed{0};
|
||||||
std::atomic<uint64_t> drops{0};
|
std::atomic<uint64_t> drops{0};
|
||||||
std::atomic<uint64_t> overflows{0};
|
std::atomic<uint64_t> overflows{0};
|
||||||
std::atomic<uint64_t> pops{0};
|
std::atomic<uint64_t> pops{0};
|
||||||
@ -24,8 +25,9 @@ struct ChannelStats {
|
|||||||
ChannelStats(const ChannelStats&) = delete;
|
ChannelStats(const ChannelStats&) = delete;
|
||||||
ChannelStats& operator=(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);
|
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);
|
std::size_t prev = peak_fill.load(std::memory_order_relaxed);
|
||||||
while (current_fill > prev &&
|
while (current_fill > prev &&
|
||||||
!peak_fill.compare_exchange_weak(prev, current_fill,
|
!peak_fill.compare_exchange_weak(prev, current_fill,
|
||||||
@ -54,6 +56,12 @@ struct NodeStats {
|
|||||||
// blocked on mutexes/channels. Sampled once per frame.
|
// blocked on mutexes/channels. Sampled once per frame.
|
||||||
std::atomic<int64_t> total_cpu_us{0}; // cumulative CPU µs consumed
|
std::atomic<int64_t> total_cpu_us{0}; // cumulative CPU µs consumed
|
||||||
|
|
||||||
|
// Pool scheduling stats — only meaningful for PoolNode / InterruptNode.
|
||||||
|
// exec_start_us: wall-clock µs when fire_once began; 0 when idle.
|
||||||
|
// Used by the watchdog to detect hung nodes (elapsed > max_exec_time).
|
||||||
|
std::atomic<int64_t> queue_wait_us{0}; // cumulative µs spent in pool queue
|
||||||
|
std::atomic<int64_t> exec_start_us{0}; // non-zero while fire_once is running
|
||||||
|
|
||||||
NodeStats() = default;
|
NodeStats() = default;
|
||||||
NodeStats(const NodeStats&) = delete;
|
NodeStats(const NodeStats&) = delete;
|
||||||
NodeStats& operator=(const NodeStats&) = delete;
|
NodeStats& operator=(const NodeStats&) = delete;
|
||||||
@ -71,6 +79,11 @@ struct NodeStats {
|
|||||||
+ static_cast<int64_t>(ts.tv_nsec) / 1'000;
|
+ static_cast<int64_t>(ts.tv_nsec) / 1'000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void record_queue_wait(duration_t wait) {
|
||||||
|
int64_t us = static_cast<int64_t>(wait.count() * 1000.0);
|
||||||
|
if (us > 0) queue_wait_us.fetch_add(us, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
void record_exec(duration_t exec_time, duration_t blocked_time,
|
void record_exec(duration_t exec_time, duration_t blocked_time,
|
||||||
const struct timespec& cpu_before, const struct timespec& cpu_after) {
|
const struct timespec& cpu_before, const struct timespec& cpu_after) {
|
||||||
frames_processed.fetch_add(1, std::memory_order_relaxed);
|
frames_processed.fetch_add(1, std::memory_order_relaxed);
|
||||||
@ -105,10 +118,11 @@ struct ChannelSnapshot {
|
|||||||
std::size_t current_fill;
|
std::size_t current_fill;
|
||||||
std::size_t peak_fill;
|
std::size_t peak_fill;
|
||||||
uint64_t pushes;
|
uint64_t pushes;
|
||||||
|
uint64_t bytes_pushed; // actual bytes accumulated via channel_data_size<T>
|
||||||
uint64_t drops;
|
uint64_t drops;
|
||||||
uint64_t overflows;
|
uint64_t overflows;
|
||||||
uint64_t pops;
|
uint64_t pops;
|
||||||
std::size_t item_bytes; // sizeof(T) for the stored type — set by Channel<T>
|
std::size_t item_bytes; // sizeof(T) — nominal struct size, not necessarily data size
|
||||||
|
|
||||||
double fill_pct() const {
|
double fill_pct() const {
|
||||||
return capacity ? 100.0 * current_fill / capacity : 0.0;
|
return capacity ? 100.0 * current_fill / capacity : 0.0;
|
||||||
@ -116,10 +130,10 @@ struct ChannelSnapshot {
|
|||||||
double peak_pct() const {
|
double peak_pct() const {
|
||||||
return capacity ? 100.0 * peak_fill / capacity : 0.0;
|
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 {
|
double bandwidth_mbs(double elapsed_s) const {
|
||||||
if (elapsed_s <= 0.0 || item_bytes == 0) return 0.0;
|
if (elapsed_s <= 0.0) return 0.0;
|
||||||
return static_cast<double>(pushes * item_bytes) / elapsed_s / 1e6;
|
return static_cast<double>(bytes_pushed) / elapsed_s / 1e6;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,10 +142,27 @@ struct NodeSnapshot {
|
|||||||
uint64_t frames_processed;
|
uint64_t frames_processed;
|
||||||
double ema_exec_ms;
|
double ema_exec_ms;
|
||||||
double max_exec_ms;
|
double max_exec_ms;
|
||||||
double total_blocked_ms;
|
double total_blocked_ms; // ThreadPerNode: time blocked in channel pop
|
||||||
double throughput_fps;
|
double throughput_fps;
|
||||||
double total_cpu_ms; // cumulative CPU time consumed by this node's thread
|
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 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) ─────────────────────────────────
|
// ── Cross-network snapshot (used by DebugHub) ─────────────────────────────────
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "channel.hpp"
|
#include "channel.hpp"
|
||||||
#include "diagnostics.hpp"
|
#include "diagnostics.hpp"
|
||||||
#include "node.hpp"
|
#include "inode.hpp"
|
||||||
#include "port.hpp"
|
#include "port.hpp"
|
||||||
#include "traits.hpp"
|
#include "traits.hpp"
|
||||||
|
|
||||||
@ -15,24 +15,6 @@
|
|||||||
|
|
||||||
namespace kpn {
|
namespace kpn {
|
||||||
|
|
||||||
namespace detail {
|
|
||||||
|
|
||||||
// Produces std::tuple<T, T, ..., T> with N repetitions — used so that
|
|
||||||
// Network::connect can do its normal return_tuple type-check against FanoutNode.
|
|
||||||
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
|
|
||||||
struct repeat_tuple;
|
|
||||||
|
|
||||||
template<typename T, std::size_t N, std::size_t... Is>
|
|
||||||
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
|
|
||||||
template<std::size_t> using always_T = T;
|
|
||||||
using type = std::tuple<always_T<Is>...>;
|
|
||||||
};
|
|
||||||
|
|
||||||
template<typename T, std::size_t N>
|
|
||||||
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
|
|
||||||
|
|
||||||
} // namespace detail
|
|
||||||
|
|
||||||
// ── FanoutNode ────────────────────────────────────────────────────────────────
|
// ── FanoutNode ────────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Reads one item from its single input channel and pushes a copy to each of
|
// Reads one item from its single input channel and pushes a copy to each of
|
||||||
@ -48,7 +30,7 @@ template<typename T, std::size_t N, std::size_t Id = 0>
|
|||||||
class FanoutNode : public INode {
|
class FanoutNode : public INode {
|
||||||
public:
|
public:
|
||||||
using args_tuple = std::tuple<T>;
|
using args_tuple = std::tuple<T>;
|
||||||
using return_tuple = detail::repeat_tuple_t<T, N>;
|
using return_tuple = repeat_tuple_t<T, N>;
|
||||||
using return_raw = return_tuple;
|
using return_raw = return_tuple;
|
||||||
|
|
||||||
static constexpr std::size_t input_count = 1;
|
static constexpr std::size_t input_count = 1;
|
||||||
|
|||||||
32
include/kpn/inode.hpp
Normal file
32
include/kpn/inode.hpp
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "diagnostics.hpp"
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace kpn {
|
||||||
|
|
||||||
|
// Called when a node's function throws. Return true to skip the failed
|
||||||
|
// invocation and keep running, false to stop the node.
|
||||||
|
using NodeErrorHandler = std::function<bool(std::string_view node_name, std::exception_ptr)>;
|
||||||
|
|
||||||
|
// ── INode — type-erased interface for Network / watchdog ─────────────────────
|
||||||
|
|
||||||
|
struct INode {
|
||||||
|
virtual ~INode() = default;
|
||||||
|
virtual void start() = 0;
|
||||||
|
virtual void stop() = 0;
|
||||||
|
virtual bool running() const = 0;
|
||||||
|
virtual const NodeStats& stats() const = 0;
|
||||||
|
virtual NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const = 0;
|
||||||
|
virtual void set_name(std::string name) = 0;
|
||||||
|
|
||||||
|
// halt(): alias for stop() — immediate, discards in-flight work.
|
||||||
|
virtual void halt() { stop(); }
|
||||||
|
|
||||||
|
// shutdown(): graceful drain before stopping. Base implementation falls
|
||||||
|
// back to stop(). Network and StaticNetwork override with topo-ordered drain.
|
||||||
|
virtual void shutdown() { stop(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace kpn
|
||||||
259
include/kpn/interrupt_node.hpp
Normal file
259
include/kpn/interrupt_node.hpp
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "channel.hpp"
|
||||||
|
#include "diagnostics.hpp"
|
||||||
|
#include "fixed_string.hpp"
|
||||||
|
#include "inode.hpp"
|
||||||
|
#include "port.hpp"
|
||||||
|
#include "scheduler.hpp"
|
||||||
|
#include "traits.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <iostream>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <tuple>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
namespace kpn {
|
||||||
|
|
||||||
|
// ── InterruptNode ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A source node (zero inputs) driven by an external event — camera frame ready,
|
||||||
|
// timer tick, socket data, etc. — rather than self-submission.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// auto node = make_interrupt_node<produce_frame>(pool, out<"frame">{});
|
||||||
|
// camera_sdk.on_frame_ready(node.get_trigger()); // register with external source
|
||||||
|
// network.add("camera", node).connect(...).build().start();
|
||||||
|
//
|
||||||
|
// The trigger callable is safe to call from any thread, including signal handlers,
|
||||||
|
// provided the underlying scheduler's submit() is signal-safe. After fire_once()
|
||||||
|
// completes the node is idle until the next trigger fires — it does NOT busy-loop.
|
||||||
|
|
||||||
|
template<auto Func,
|
||||||
|
typename OutputTag = out<>,
|
||||||
|
fixed_string Label = "",
|
||||||
|
std::size_t UniqueTag = 0>
|
||||||
|
class InterruptNode;
|
||||||
|
|
||||||
|
template<auto Func, fixed_string... OutNames, fixed_string Label, std::size_t UniqueTag>
|
||||||
|
class InterruptNode<Func, out<OutNames...>, Label, UniqueTag> : public INode {
|
||||||
|
public:
|
||||||
|
using F = decltype(Func);
|
||||||
|
using return_raw = return_t<F>;
|
||||||
|
using return_tuple = normalised_return_t<return_raw>;
|
||||||
|
|
||||||
|
static_assert(arity_v<F> == 0,
|
||||||
|
"InterruptNode function must take no arguments (it has no input channels)");
|
||||||
|
|
||||||
|
static constexpr std::string_view label() { return Label.view(); }
|
||||||
|
static constexpr std::size_t unique_tag = UniqueTag;
|
||||||
|
static constexpr std::size_t input_count = 0;
|
||||||
|
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
||||||
|
|
||||||
|
static_assert(
|
||||||
|
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
||||||
|
"make_interrupt_node: number of output names must match return tuple size, or provide none"
|
||||||
|
);
|
||||||
|
|
||||||
|
explicit InterruptNode(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5)
|
||||||
|
: scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
|
||||||
|
{}
|
||||||
|
|
||||||
|
~InterruptNode() override { stop(); }
|
||||||
|
|
||||||
|
// ── INode ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void start() override {
|
||||||
|
stop_flag_.store(false, std::memory_order_relaxed);
|
||||||
|
pending_.store(0, std::memory_order_relaxed);
|
||||||
|
// Does NOT self-submit — waits for first external trigger.
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() override {
|
||||||
|
stop_flag_.store(true, std::memory_order_seq_cst);
|
||||||
|
// In-flight fire_once() observes stop_flag_ on its next check.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool running() const override { return !stop_flag_.load(std::memory_order_relaxed); }
|
||||||
|
void set_name(std::string name) override { name_ = std::move(name); }
|
||||||
|
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||||
|
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
|
||||||
|
|
||||||
|
const NodeStats& stats() const override { return stats_; }
|
||||||
|
|
||||||
|
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||||
|
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||||
|
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double qwait_ms = stats_.queue_wait_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double total_ms = exec_ms; // no blocked time for interrupt nodes
|
||||||
|
return {
|
||||||
|
name, frames, exec_ms,
|
||||||
|
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
0.0, // blocked_ms — not applicable
|
||||||
|
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||||
|
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
total_ms > 0 ? 100.0 : 0.0,
|
||||||
|
qwait_ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port access — by index ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
OutputPort<InterruptNode, I> output() {
|
||||||
|
static_assert(I < output_count, "output index out of range");
|
||||||
|
return {*this};
|
||||||
|
}
|
||||||
|
|
||||||
|
template<fixed_string Name>
|
||||||
|
auto output() {
|
||||||
|
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
||||||
|
static_assert(idx != npos, "unknown output port name");
|
||||||
|
return output<idx>();
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
||||||
|
std::get<I>(output_channels_) = ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trigger ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Returns a callable that fires this node when called.
|
||||||
|
// Pass it to a camera SDK, timer, or any external event source.
|
||||||
|
// Thread-safe; may be called from any thread.
|
||||||
|
std::function<void()> get_trigger() {
|
||||||
|
return [this] { trigger(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Each trigger() increments pending_. When going 0→1 a task is submitted.
|
||||||
|
// Each fire_once() handles one pending event and decrements; if more remain
|
||||||
|
// (old value > 1) it resubmits itself. This guarantees every trigger produces
|
||||||
|
// exactly one execution even if triggers arrive faster than fire_once completes.
|
||||||
|
void trigger() {
|
||||||
|
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||||
|
if (pending_.fetch_add(1, std::memory_order_acq_rel) == 0)
|
||||||
|
scheduler_->submit([this] { fire_once(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void fire_once() {
|
||||||
|
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||||
|
pending_.store(0, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto t0 = clock_t::now();
|
||||||
|
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||||
|
t0.time_since_epoch()).count();
|
||||||
|
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
bool fatal = false;
|
||||||
|
try {
|
||||||
|
auto t1 = clock_t::now();
|
||||||
|
stats_.record_queue_wait(duration_t(t1 - t0));
|
||||||
|
auto cpu0 = NodeStats::cpu_now();
|
||||||
|
|
||||||
|
if constexpr (std::is_void_v<return_raw>) {
|
||||||
|
Func();
|
||||||
|
} else {
|
||||||
|
auto result = Func();
|
||||||
|
push_outputs(normalise(std::move(result)),
|
||||||
|
std::make_index_sequence<output_count>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
auto cpu1 = NodeStats::cpu_now();
|
||||||
|
auto t2 = clock_t::now();
|
||||||
|
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||||
|
} catch (const ChannelOverflowError& e) {
|
||||||
|
std::cerr << "[kpn] interrupt node overflow: " << e.what() << "\n";
|
||||||
|
} catch (...) {
|
||||||
|
if (!error_handler_ || !error_handler_(name_, std::current_exception()))
|
||||||
|
fatal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
if (fatal) {
|
||||||
|
pending_.store(0, std::memory_order_release);
|
||||||
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement and resubmit only if more triggers are queued.
|
||||||
|
// fetch_sub returns old value; old > 1 means new > 0.
|
||||||
|
if (pending_.fetch_sub(1, std::memory_order_acq_rel) > 1)
|
||||||
|
scheduler_->submit([this] { fire_once(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename R = return_raw>
|
||||||
|
static return_tuple normalise(R&& r) {
|
||||||
|
if constexpr (is_tuple_v<R>) return std::move(r);
|
||||||
|
else return std::make_tuple(std::move(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||||
|
(push_one<Is>(std::get<Is>(std::move(result))), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
|
||||||
|
auto* ch = std::get<I>(output_channels_);
|
||||||
|
if (!ch) return;
|
||||||
|
try { ch->push(std::move(val)); }
|
||||||
|
catch (const ChannelOverflowError&) {
|
||||||
|
throw ChannelOverflowError(ch->capacity(),
|
||||||
|
"interrupt node '" + name_ + "' " + output_port_label<I>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
static std::string output_port_label() {
|
||||||
|
if constexpr (sizeof...(OutNames) > 0) {
|
||||||
|
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
|
||||||
|
return std::string("output['") + std::string(names[I]) + "']";
|
||||||
|
} else {
|
||||||
|
return "output[" + std::to_string(I) + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Tup, std::size_t... Is>
|
||||||
|
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
||||||
|
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
||||||
|
|
||||||
|
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||||
|
std::make_index_sequence<output_count>{}));
|
||||||
|
|
||||||
|
std::shared_ptr<IScheduler> scheduler_;
|
||||||
|
std::string name_;
|
||||||
|
std::size_t fifo_capacity_;
|
||||||
|
output_channels_t output_channels_{};
|
||||||
|
std::atomic<bool> stop_flag_{true};
|
||||||
|
std::atomic<int> pending_{0}; // triggers awaiting execution
|
||||||
|
NodeStats stats_;
|
||||||
|
NodeErrorHandler error_handler_;
|
||||||
|
std::chrono::milliseconds max_exec_time_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── make_interrupt_node factory ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
|
||||||
|
auto make_interrupt_node(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5) {
|
||||||
|
return InterruptNode<Func, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||||
|
fixed_string... OutNames>
|
||||||
|
auto make_interrupt_node(std::shared_ptr<IScheduler> sched, out<OutNames...>,
|
||||||
|
std::size_t fifo_capacity = 5) {
|
||||||
|
return InterruptNode<Func, out<OutNames...>, Label, UniqueTag>(
|
||||||
|
std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace kpn
|
||||||
@ -5,8 +5,13 @@
|
|||||||
#include "traits.hpp"
|
#include "traits.hpp"
|
||||||
#include "channel.hpp"
|
#include "channel.hpp"
|
||||||
#include "port.hpp"
|
#include "port.hpp"
|
||||||
|
#include "inode.hpp"
|
||||||
|
#include "scheduler.hpp"
|
||||||
|
#include "pool_node.hpp"
|
||||||
|
#include "interrupt_node.hpp"
|
||||||
#include "node.hpp"
|
#include "node.hpp"
|
||||||
#include "fanout.hpp"
|
#include "fanout.hpp"
|
||||||
|
#include "branch.hpp"
|
||||||
#include "shared_resource.hpp"
|
#include "shared_resource.hpp"
|
||||||
#include "static_network.hpp"
|
#include "static_network.hpp"
|
||||||
#include "debug_hub.hpp"
|
#include "debug_hub.hpp"
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
#include "channel.hpp"
|
#include "channel.hpp"
|
||||||
#include "diagnostics.hpp"
|
#include "diagnostics.hpp"
|
||||||
#include "fixed_string.hpp"
|
#include "fixed_string.hpp"
|
||||||
#include "node.hpp" // INode
|
#include "inode.hpp"
|
||||||
#include "port.hpp"
|
#include "port.hpp"
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "diagnostics.hpp"
|
#include "diagnostics.hpp"
|
||||||
#include "node.hpp"
|
#include "inode.hpp"
|
||||||
#include "port.hpp"
|
#include "port.hpp"
|
||||||
|
|
||||||
#ifdef KPN_WEB_DEBUG
|
#ifdef KPN_WEB_DEBUG
|
||||||
@ -126,14 +126,17 @@ public:
|
|||||||
web_debug_port_,
|
web_debug_port_,
|
||||||
[this]() {
|
[this]() {
|
||||||
auto s = collect_snapshots();
|
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();
|
web_server_->start();
|
||||||
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
|
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
|
||||||
#endif
|
#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
|
#ifdef KPN_WEB_DEBUG
|
||||||
if (web_server_) web_server_->stop();
|
if (web_server_) web_server_->stop();
|
||||||
#endif
|
#endif
|
||||||
@ -142,6 +145,37 @@ public:
|
|||||||
nodes_.at(*it)->stop();
|
nodes_.at(*it)->stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shutdown(): graceful drain in topological order.
|
||||||
|
// Stops source nodes first, polls until their output channels drain to zero,
|
||||||
|
// then stops the next layer, and so on.
|
||||||
|
void shutdown() override {
|
||||||
|
#ifdef KPN_WEB_DEBUG
|
||||||
|
if (web_server_) web_server_->stop();
|
||||||
|
#endif
|
||||||
|
stop_watchdog();
|
||||||
|
|
||||||
|
// Identify which nodes have no incoming edges (sources).
|
||||||
|
std::map<std::string, std::size_t> in_degree;
|
||||||
|
for (auto& [name, _] : nodes_) in_degree[name] = 0;
|
||||||
|
for (auto& [src, dsts] : adj_)
|
||||||
|
for (auto& dst : dsts) in_degree[dst]++;
|
||||||
|
|
||||||
|
// Walk topo order: stop each source layer, wait for its output channels
|
||||||
|
// to drain, then proceed to the next layer.
|
||||||
|
std::set<std::string> stopped;
|
||||||
|
for (auto& name : topo_) {
|
||||||
|
if (in_degree[name] == 0 || all_predecessors_stopped(name, stopped)) {
|
||||||
|
nodes_.at(name)->stop();
|
||||||
|
stopped.insert(name);
|
||||||
|
// Wait for output channels of this node to drain.
|
||||||
|
drain_output_channels(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stop any remaining nodes (sinks / nodes not yet stopped).
|
||||||
|
for (auto it = topo_.rbegin(); it != topo_.rend(); ++it)
|
||||||
|
if (!stopped.count(*it)) nodes_.at(*it)->stop();
|
||||||
|
}
|
||||||
|
|
||||||
bool running() const override { return watchdog_.joinable(); }
|
bool running() const override { return watchdog_.joinable(); }
|
||||||
void set_name(std::string) override {}
|
void set_name(std::string) override {}
|
||||||
|
|
||||||
@ -163,6 +197,10 @@ public:
|
|||||||
void set_error_handler(ErrorHandler h) { error_handler_ = std::move(h); }
|
void set_error_handler(ErrorHandler h) { error_handler_ = std::move(h); }
|
||||||
void set_diagnostics_handler(DiagnosticsHandler h) { diag_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
|
#ifdef KPN_WEB_DEBUG
|
||||||
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
|
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
|
||||||
#endif
|
#endif
|
||||||
@ -171,7 +209,7 @@ public:
|
|||||||
// Can be called at any time; thread-safe (reads atomics with relaxed ordering).
|
// Can be called at any time; thread-safe (reads atomics with relaxed ordering).
|
||||||
void print_diagnostics(std::ostream& os = std::cerr) const {
|
void print_diagnostics(std::ostream& os = std::cerr) const {
|
||||||
auto s = collect_snapshots();
|
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:
|
private:
|
||||||
@ -180,6 +218,7 @@ private:
|
|||||||
struct Snapshots {
|
struct Snapshots {
|
||||||
std::vector<NodeSnapshot> nodes;
|
std::vector<NodeSnapshot> nodes;
|
||||||
std::vector<ChannelSnapshot> channels;
|
std::vector<ChannelSnapshot> channels;
|
||||||
|
std::vector<PoolSnapshot> pools;
|
||||||
double elapsed_s;
|
double elapsed_s;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -195,11 +234,16 @@ private:
|
|||||||
for (auto& probe : channel_probes_)
|
for (auto& probe : channel_probes_)
|
||||||
channels.push_back(probe->snapshot());
|
channels.push_back(probe->snapshot());
|
||||||
|
|
||||||
return {std::move(nodes), std::move(channels), elapsed_s};
|
std::vector<PoolSnapshot> pools;
|
||||||
|
for (auto& [name, probe] : pool_probes_)
|
||||||
|
pools.push_back(probe->snapshot(name));
|
||||||
|
|
||||||
|
return {std::move(nodes), std::move(channels), std::move(pools), elapsed_s};
|
||||||
}
|
}
|
||||||
|
|
||||||
static std::string format_report(const std::vector<NodeSnapshot>& nodes,
|
static std::string format_report(const std::vector<NodeSnapshot>& nodes,
|
||||||
const std::vector<ChannelSnapshot>& channels,
|
const std::vector<ChannelSnapshot>& channels,
|
||||||
|
const std::vector<PoolSnapshot>& pools = {},
|
||||||
double elapsed_s = 0.0) {
|
double elapsed_s = 0.0) {
|
||||||
std::ostringstream os;
|
std::ostringstream os;
|
||||||
os << std::fixed << std::setprecision(1);
|
os << std::fixed << std::setprecision(1);
|
||||||
@ -258,6 +302,31 @@ private:
|
|||||||
<< flag << "\n";
|
<< 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
|
// Bottleneck hint: node with highest ema_exec_ms
|
||||||
if (!nodes.empty()) {
|
if (!nodes.empty()) {
|
||||||
auto it = std::max_element(nodes.begin(), nodes.end(),
|
auto it = std::max_element(nodes.begin(), nodes.end(),
|
||||||
@ -271,6 +340,31 @@ private:
|
|||||||
return os.str();
|
return os.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shutdown helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
bool all_predecessors_stopped(const std::string& name,
|
||||||
|
const std::set<std::string>& stopped) const {
|
||||||
|
for (auto& [src, dsts] : adj_)
|
||||||
|
for (auto& dst : dsts)
|
||||||
|
if (dst == name && !stopped.count(src)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void drain_output_channels(const std::string& /*name*/) const {
|
||||||
|
// Poll all channel probes until none report non-zero fill.
|
||||||
|
// A short sleep prevents busy-spin; 1 ms is fine for drain purposes.
|
||||||
|
bool any_full = true;
|
||||||
|
while (any_full) {
|
||||||
|
any_full = false;
|
||||||
|
for (auto& probe : channel_probes_) {
|
||||||
|
auto snap = probe->snapshot();
|
||||||
|
if (snap.current_fill > 0) { any_full = true; break; }
|
||||||
|
}
|
||||||
|
if (any_full)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Cycle detection / topological sort ───────────────────────────────────
|
// ── Cycle detection / topological sort ───────────────────────────────────
|
||||||
|
|
||||||
void dfs(const std::string& name, std::map<std::string, int>& color) {
|
void dfs(const std::string& name, std::map<std::string, int>& color) {
|
||||||
@ -292,16 +386,33 @@ private:
|
|||||||
if (tok.stop_requested()) break;
|
if (tok.stop_requested()) break;
|
||||||
|
|
||||||
auto s = collect_snapshots();
|
auto s = collect_snapshots();
|
||||||
|
check_hung_nodes();
|
||||||
|
|
||||||
if (diag_handler_) {
|
if (diag_handler_) {
|
||||||
diag_handler_(s.nodes, s.channels);
|
diag_handler_(s.nodes, s.channels);
|
||||||
} else {
|
} else {
|
||||||
std::cerr << format_report(s.nodes, s.channels, s.elapsed_s);
|
std::cerr << format_report(s.nodes, s.channels, s.pools, s.elapsed_s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void check_hung_nodes() const {
|
||||||
|
auto now_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||||
|
clock_t::now().time_since_epoch()).count();
|
||||||
|
for (auto& [name, node] : nodes_) {
|
||||||
|
int64_t start = node->stats().exec_start_us.load(std::memory_order_relaxed);
|
||||||
|
if (start == 0) continue;
|
||||||
|
int64_t elapsed_ms = (now_us - start) / 1000;
|
||||||
|
// Warn if a node has been executing for > 5 s with no max_exec_time set,
|
||||||
|
// or if it exceeds its configured max. Threshold: 5000 ms default.
|
||||||
|
if (elapsed_ms > 5000) {
|
||||||
|
std::cerr << "[kpn] WARNING: node '" << name
|
||||||
|
<< "' has been executing for " << elapsed_ms << " ms\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void stop_watchdog() {
|
void stop_watchdog() {
|
||||||
if (watchdog_.joinable())
|
if (watchdog_.joinable())
|
||||||
watchdog_.request_stop(), watchdog_.join();
|
watchdog_.request_stop(), watchdog_.join();
|
||||||
@ -316,6 +427,7 @@ private:
|
|||||||
std::map<std::string, std::string> exposed_outputs_;
|
std::map<std::string, std::string> exposed_outputs_;
|
||||||
std::set<std::pair<std::string, std::size_t>> connected_outputs_;
|
std::set<std::pair<std::string, std::size_t>> connected_outputs_;
|
||||||
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
|
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
|
||||||
|
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
|
||||||
ErrorHandler error_handler_;
|
ErrorHandler error_handler_;
|
||||||
DiagnosticsHandler diag_handler_;
|
DiagnosticsHandler diag_handler_;
|
||||||
std::chrono::milliseconds watchdog_interval_{3000};
|
std::chrono::milliseconds watchdog_interval_{3000};
|
||||||
|
|||||||
@ -1,46 +1,29 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "channel.hpp"
|
#include "inode.hpp"
|
||||||
#include "diagnostics.hpp"
|
#include "pool_node.hpp" // PoolNode, PoolObjectNode
|
||||||
#include "fixed_string.hpp"
|
|
||||||
#include "port.hpp"
|
|
||||||
#include "traits.hpp"
|
|
||||||
|
|
||||||
#include <array>
|
// node.hpp — Node<> and ObjectNode<> as thin wrappers over PoolNode<>.
|
||||||
#include <atomic>
|
//
|
||||||
#include <chrono>
|
// Each Node owns a private single-thread ThreadPool so the API is unchanged:
|
||||||
#include <cstddef>
|
// node.start() / node.stop() are self-contained with no external scheduler.
|
||||||
#include <functional>
|
// Internally, all execution goes through PoolNode::fire_once() — the same code
|
||||||
#include <iostream>
|
// path as explicitly pool-scheduled nodes.
|
||||||
#include <memory>
|
//
|
||||||
#include <stdexcept>
|
// To share a thread pool across multiple nodes, use make_pool_node() directly.
|
||||||
#include <thread>
|
|
||||||
#include <tuple>
|
|
||||||
#include <type_traits>
|
|
||||||
|
|
||||||
namespace kpn {
|
namespace kpn {
|
||||||
|
|
||||||
// Called when a node's function throws. Return true to skip the failed
|
namespace detail {
|
||||||
// invocation and keep running, false to stop the node.
|
|
||||||
using NodeErrorHandler = std::function<bool(std::string_view node_name, std::exception_ptr)>;
|
|
||||||
|
|
||||||
// ── INode — type-erased interface for Network / watchdog ─────────────────────
|
// Private base initialized before PoolNode so its pool can be passed to the
|
||||||
|
// PoolNode constructor (C++ initialises bases left-to-right).
|
||||||
struct INode {
|
struct NodePrivatePool {
|
||||||
virtual ~INode() = default;
|
std::shared_ptr<ThreadPool> pool{std::make_shared<ThreadPool>(1)};
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
} // namespace detail
|
||||||
|
|
||||||
// ── Node ─────────────────────────────────────────────────────────────────────
|
// ── Node ─────────────────────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// Template parameters:
|
|
||||||
// Func — the wrapped function (auto NTTP, deduced as a function pointer)
|
|
||||||
// InputNames — optional kpn::in<"a","b"> tag type (at most one)
|
|
||||||
// OutputNames — optional kpn::out<"x","y"> tag type (at most one)
|
|
||||||
|
|
||||||
template<auto Func,
|
template<auto Func,
|
||||||
typename InputTag = in<>,
|
typename InputTag = in<>,
|
||||||
@ -49,267 +32,25 @@ template<auto Func,
|
|||||||
std::size_t UniqueTag = 0>
|
std::size_t UniqueTag = 0>
|
||||||
class Node;
|
class Node;
|
||||||
|
|
||||||
// Specialisation that unpacks the in<>/out<> tag packs
|
|
||||||
template<auto Func, fixed_string... InNames, fixed_string... OutNames,
|
template<auto Func, fixed_string... InNames, fixed_string... OutNames,
|
||||||
fixed_string Label, std::size_t UniqueTag>
|
fixed_string Label, std::size_t UniqueTag>
|
||||||
class Node<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
|
class Node<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>
|
||||||
|
: private detail::NodePrivatePool
|
||||||
|
, public PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
|
||||||
|
using Base = PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
|
||||||
public:
|
public:
|
||||||
using F = decltype(Func);
|
|
||||||
using args_tuple = args_t<F>;
|
|
||||||
using return_raw = return_t<F>;
|
|
||||||
using return_tuple = normalised_return_t<return_raw>;
|
|
||||||
|
|
||||||
// Identity accessors — used by StaticNetwork for diagnostics and type-level uniqueness
|
|
||||||
static constexpr std::string_view label() { return Label.view(); }
|
|
||||||
static constexpr std::size_t unique_tag = UniqueTag;
|
|
||||||
|
|
||||||
static constexpr std::size_t input_count = arity_v<F>;
|
|
||||||
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
|
||||||
|
|
||||||
static_assert(
|
|
||||||
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
|
||||||
"make_node: number of input names must match function arity, or provide none"
|
|
||||||
);
|
|
||||||
static_assert(
|
|
||||||
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
|
||||||
"make_node: number of output names must match return tuple size, or provide none"
|
|
||||||
);
|
|
||||||
|
|
||||||
explicit Node(std::size_t fifo_capacity = 5)
|
explicit Node(std::size_t fifo_capacity = 5)
|
||||||
: fifo_capacity_(fifo_capacity)
|
: detail::NodePrivatePool{}
|
||||||
{
|
, Base(pool, fifo_capacity)
|
||||||
init_input_channels(std::make_index_sequence<input_count>{});
|
{}
|
||||||
}
|
|
||||||
|
|
||||||
~Node() override { stop(); }
|
~Node() override { stop(); }
|
||||||
|
|
||||||
// ── INode ─────────────────────────────────────────────────────────────────
|
void start() override { pool->start(); Base::start(); }
|
||||||
|
void stop() override { Base::stop(); pool->stop(); }
|
||||||
void start() override {
|
|
||||||
enable_inputs(std::make_index_sequence<input_count>{});
|
|
||||||
stop_flag_.store(false, std::memory_order_relaxed);
|
|
||||||
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
void stop() override {
|
|
||||||
stop_flag_.store(true, std::memory_order_relaxed);
|
|
||||||
// Disable all input channels: drops queued items and unblocks waiting pop()
|
|
||||||
disable_inputs(std::make_index_sequence<input_count>{});
|
|
||||||
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool running() const override {
|
|
||||||
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_name(std::string name) override { name_ = std::move(name); }
|
|
||||||
|
|
||||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
|
||||||
|
|
||||||
const NodeStats& stats() const override { return stats_; }
|
|
||||||
|
|
||||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
|
||||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
|
||||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
|
||||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
|
||||||
double total_ms = exec_ms + blocked_ms;
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
frames,
|
|
||||||
exec_ms,
|
|
||||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
|
||||||
blocked_ms,
|
|
||||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
|
||||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
|
||||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Port access — by index ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
InputPort<Node, I> input() {
|
|
||||||
static_assert(I < input_count, "input index out of range");
|
|
||||||
return {*this};
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
OutputPort<Node, I> output() {
|
|
||||||
static_assert(I < output_count, "output index out of range");
|
|
||||||
return {*this};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Port access — by name ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
template<fixed_string Name>
|
|
||||||
auto input() {
|
|
||||||
constexpr std::size_t idx = index_of<Name, InNames...>();
|
|
||||||
static_assert(idx != npos, "unknown input port name");
|
|
||||||
return input<idx>();
|
|
||||||
}
|
|
||||||
|
|
||||||
template<fixed_string Name>
|
|
||||||
auto output() {
|
|
||||||
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
|
||||||
static_assert(idx != npos, "unknown output port name");
|
|
||||||
return output<idx>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal channel accessors (used by Network at connect time) ──────────
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
|
|
||||||
return *std::get<I>(input_channels_);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace the owned input channel with an externally provided one.
|
|
||||||
// Used by VariantNodeWrapper to share a Channel<T> with a VariantChannel adapter.
|
|
||||||
template<std::size_t I>
|
|
||||||
void set_input_channel(
|
|
||||||
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
|
|
||||||
std::get<I>(input_channels_) = std::move(ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
void set_output_channel(
|
|
||||||
Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
|
||||||
std::get<I>(output_channels_) = ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
// ── Channel storage ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Input channels — shared ownership so VariantChannel adapters can share them
|
|
||||||
template<std::size_t... Is>
|
|
||||||
void init_input_channels(std::index_sequence<Is...>) {
|
|
||||||
((std::get<Is>(input_channels_) =
|
|
||||||
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
|
|
||||||
...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t... Is>
|
|
||||||
void enable_inputs(std::index_sequence<Is...>) {
|
|
||||||
(std::get<Is>(input_channels_)->enable(), ...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t... Is>
|
|
||||||
void disable_inputs(std::index_sequence<Is...>) {
|
|
||||||
(std::get<Is>(input_channels_)->disable(), ...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<typename Tup, std::size_t... Is>
|
|
||||||
static auto make_input_channel_tuple(std::index_sequence<Is...>)
|
|
||||||
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
|
|
||||||
|
|
||||||
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
|
|
||||||
std::make_index_sequence<input_count>{}));
|
|
||||||
|
|
||||||
// Output channels — non-owning pointers, set at connect time
|
|
||||||
template<typename Tup, std::size_t... Is>
|
|
||||||
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
|
||||||
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
|
||||||
|
|
||||||
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
|
||||||
std::make_index_sequence<output_count>{}));
|
|
||||||
|
|
||||||
// ── run_loop ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void run_loop() {
|
|
||||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
|
||||||
try {
|
|
||||||
auto t0 = clock_t::now();
|
|
||||||
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
|
||||||
auto t1 = clock_t::now();
|
|
||||||
auto cpu0 = NodeStats::cpu_now();
|
|
||||||
|
|
||||||
if constexpr (std::is_void_v<return_raw>) {
|
|
||||||
std::apply(Func, args);
|
|
||||||
} else {
|
|
||||||
auto result = std::apply(Func, args);
|
|
||||||
push_outputs(normalise(std::move(result)),
|
|
||||||
std::make_index_sequence<output_count>{});
|
|
||||||
}
|
|
||||||
|
|
||||||
auto cpu1 = NodeStats::cpu_now();
|
|
||||||
auto t2 = clock_t::now();
|
|
||||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
|
||||||
} catch (const ChannelClosedError&) {
|
|
||||||
break;
|
|
||||||
} catch (const ChannelOverflowError& e) {
|
|
||||||
std::cerr << "[kpn] overflow: " << e.what() << "\n";
|
|
||||||
} catch (...) {
|
|
||||||
if (error_handler_ && error_handler_(name_, std::current_exception()))
|
|
||||||
continue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stop_flag_.store(true, std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pop all inputs into a tuple of argument values
|
|
||||||
template<std::size_t... Is>
|
|
||||||
args_tuple pop_inputs(std::index_sequence<Is...>) {
|
|
||||||
return {std::get<Is>(input_channels_)->pop()...};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalise return value to tuple (handles void and single-value returns)
|
|
||||||
template<typename R = return_raw>
|
|
||||||
static return_tuple normalise(R&& r) {
|
|
||||||
if constexpr (is_tuple_v<R>)
|
|
||||||
return std::move(r);
|
|
||||||
else
|
|
||||||
return std::make_tuple(std::move(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
static return_tuple normalise_void() { return {}; }
|
|
||||||
|
|
||||||
// Push each output element to its connected channel (if connected)
|
|
||||||
template<std::size_t... Is>
|
|
||||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
|
||||||
(push_one<Is>(std::get<Is>(std::move(result))), ...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
|
|
||||||
auto* ch = std::get<I>(output_channels_);
|
|
||||||
if (!ch) return;
|
|
||||||
try {
|
|
||||||
ch->push(std::move(val));
|
|
||||||
} catch (const ChannelOverflowError&) {
|
|
||||||
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
static std::string output_port_label() {
|
|
||||||
if constexpr (sizeof...(OutNames) > 0) {
|
|
||||||
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
|
|
||||||
return std::string("output['") + std::string(names[I]) + "']";
|
|
||||||
} else {
|
|
||||||
return "output[" + std::to_string(I) + "]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
std::string name_;
|
|
||||||
std::size_t fifo_capacity_;
|
|
||||||
input_channels_t input_channels_;
|
|
||||||
output_channels_t output_channels_{};
|
|
||||||
std::atomic<bool> stop_flag_{false};
|
|
||||||
std::jthread thread_;
|
|
||||||
NodeStats stats_;
|
|
||||||
NodeErrorHandler error_handler_;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── ObjectNode — wraps a callable object (functor / class with operator()) ────
|
// ── ObjectNode ────────────────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
template<typename Obj,
|
template<typename Obj,
|
||||||
typename InputTag = in<>,
|
typename InputTag = in<>,
|
||||||
@ -320,230 +61,20 @@ class ObjectNode;
|
|||||||
|
|
||||||
template<typename Obj, fixed_string... InNames, fixed_string... OutNames,
|
template<typename Obj, fixed_string... InNames, fixed_string... OutNames,
|
||||||
fixed_string Label, std::size_t UniqueTag>
|
fixed_string Label, std::size_t UniqueTag>
|
||||||
class ObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
|
class ObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>
|
||||||
|
: private detail::NodePrivatePool
|
||||||
|
, public PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
|
||||||
|
using Base = PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
|
||||||
public:
|
public:
|
||||||
using F = decltype(&Obj::operator());
|
|
||||||
using args_tuple = args_t<F>;
|
|
||||||
using return_raw = return_t<F>;
|
|
||||||
using return_tuple = normalised_return_t<return_raw>;
|
|
||||||
|
|
||||||
static constexpr std::string_view label() { return Label.view(); }
|
|
||||||
static constexpr std::size_t unique_tag = UniqueTag;
|
|
||||||
|
|
||||||
static constexpr std::size_t input_count = arity_v<F>;
|
|
||||||
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
|
||||||
|
|
||||||
static_assert(
|
|
||||||
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
|
||||||
"make_node: number of input names must match operator() arity, or provide none"
|
|
||||||
);
|
|
||||||
static_assert(
|
|
||||||
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
|
||||||
"make_node: number of output names must match return tuple size, or provide none"
|
|
||||||
);
|
|
||||||
|
|
||||||
explicit ObjectNode(Obj& obj, std::size_t fifo_capacity = 5)
|
explicit ObjectNode(Obj& obj, std::size_t fifo_capacity = 5)
|
||||||
: obj_(obj), fifo_capacity_(fifo_capacity)
|
: detail::NodePrivatePool{}
|
||||||
{
|
, Base(obj, pool, fifo_capacity)
|
||||||
init_input_channels(std::make_index_sequence<input_count>{});
|
{}
|
||||||
}
|
|
||||||
|
|
||||||
~ObjectNode() override { stop(); }
|
~ObjectNode() override { stop(); }
|
||||||
|
|
||||||
// ── INode ─────────────────────────────────────────────────────────────────
|
void start() override { pool->start(); Base::start(); }
|
||||||
|
void stop() override { Base::stop(); pool->stop(); }
|
||||||
void start() override {
|
|
||||||
enable_inputs(std::make_index_sequence<input_count>{});
|
|
||||||
stop_flag_.store(false, std::memory_order_relaxed);
|
|
||||||
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
void stop() override {
|
|
||||||
stop_flag_.store(true, std::memory_order_relaxed);
|
|
||||||
disable_inputs(std::make_index_sequence<input_count>{});
|
|
||||||
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool running() const override {
|
|
||||||
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_name(std::string name) override { name_ = std::move(name); }
|
|
||||||
|
|
||||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
|
||||||
|
|
||||||
const NodeStats& stats() const override { return stats_; }
|
|
||||||
|
|
||||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
|
||||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
|
||||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
|
||||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
|
||||||
double total_ms = exec_ms + blocked_ms;
|
|
||||||
return {
|
|
||||||
name, frames,
|
|
||||||
exec_ms,
|
|
||||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
|
||||||
blocked_ms,
|
|
||||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
|
||||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
|
||||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Port access ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
InputPort<ObjectNode, I> input() {
|
|
||||||
static_assert(I < input_count, "input index out of range");
|
|
||||||
return {*this};
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
OutputPort<ObjectNode, I> output() {
|
|
||||||
static_assert(I < output_count, "output index out of range");
|
|
||||||
return {*this};
|
|
||||||
}
|
|
||||||
|
|
||||||
template<fixed_string Name>
|
|
||||||
auto input() {
|
|
||||||
constexpr std::size_t idx = index_of<Name, InNames...>();
|
|
||||||
static_assert(idx != npos, "unknown input port name");
|
|
||||||
return input<idx>();
|
|
||||||
}
|
|
||||||
|
|
||||||
template<fixed_string Name>
|
|
||||||
auto output() {
|
|
||||||
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
|
||||||
static_assert(idx != npos, "unknown output port name");
|
|
||||||
return output<idx>();
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
|
|
||||||
return *std::get<I>(input_channels_);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
void set_input_channel(
|
|
||||||
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
|
|
||||||
std::get<I>(input_channels_) = std::move(ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
|
||||||
std::get<I>(output_channels_) = ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
template<std::size_t... Is>
|
|
||||||
void init_input_channels(std::index_sequence<Is...>) {
|
|
||||||
((std::get<Is>(input_channels_) =
|
|
||||||
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
|
|
||||||
...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t... Is>
|
|
||||||
void enable_inputs(std::index_sequence<Is...>) {
|
|
||||||
(std::get<Is>(input_channels_)->enable(), ...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t... Is>
|
|
||||||
void disable_inputs(std::index_sequence<Is...>) {
|
|
||||||
(std::get<Is>(input_channels_)->disable(), ...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<typename Tup, std::size_t... Is>
|
|
||||||
static auto make_input_channel_tuple(std::index_sequence<Is...>)
|
|
||||||
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
|
|
||||||
|
|
||||||
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
|
|
||||||
std::make_index_sequence<input_count>{}));
|
|
||||||
|
|
||||||
template<typename Tup, std::size_t... Is>
|
|
||||||
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
|
||||||
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
|
||||||
|
|
||||||
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
|
||||||
std::make_index_sequence<output_count>{}));
|
|
||||||
|
|
||||||
void run_loop() {
|
|
||||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
|
||||||
try {
|
|
||||||
auto t0 = clock_t::now();
|
|
||||||
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
|
||||||
auto t1 = clock_t::now();
|
|
||||||
auto cpu0 = NodeStats::cpu_now();
|
|
||||||
|
|
||||||
if constexpr (std::is_void_v<return_raw>) {
|
|
||||||
std::apply([this](auto&&... a) { obj_(std::forward<decltype(a)>(a)...); }, args);
|
|
||||||
} else {
|
|
||||||
auto result = std::apply([this](auto&&... a) { return obj_(std::forward<decltype(a)>(a)...); }, args);
|
|
||||||
push_outputs(normalise(std::move(result)),
|
|
||||||
std::make_index_sequence<output_count>{});
|
|
||||||
}
|
|
||||||
|
|
||||||
auto cpu1 = NodeStats::cpu_now();
|
|
||||||
auto t2 = clock_t::now();
|
|
||||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
|
||||||
} catch (const ChannelClosedError&) {
|
|
||||||
break;
|
|
||||||
} catch (const ChannelOverflowError& e) {
|
|
||||||
std::cerr << "[kpn] overflow: " << e.what() << "\n";
|
|
||||||
} catch (...) {
|
|
||||||
if (error_handler_ && error_handler_(name_, std::current_exception()))
|
|
||||||
continue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stop_flag_.store(true, std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t... Is>
|
|
||||||
args_tuple pop_inputs(std::index_sequence<Is...>) {
|
|
||||||
return {std::get<Is>(input_channels_)->pop()...};
|
|
||||||
}
|
|
||||||
|
|
||||||
template<typename R = return_raw>
|
|
||||||
static return_tuple normalise(R&& r) {
|
|
||||||
if constexpr (is_tuple_v<R>) return std::move(r);
|
|
||||||
else return std::make_tuple(std::move(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t... Is>
|
|
||||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
|
||||||
(push_one<Is>(std::get<Is>(std::move(result))), ...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
|
|
||||||
auto* ch = std::get<I>(output_channels_);
|
|
||||||
if (!ch) return;
|
|
||||||
try {
|
|
||||||
ch->push(std::move(val));
|
|
||||||
} catch (const ChannelOverflowError&) {
|
|
||||||
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
template<std::size_t I>
|
|
||||||
static std::string output_port_label() {
|
|
||||||
if constexpr (sizeof...(OutNames) > 0) {
|
|
||||||
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
|
|
||||||
return std::string("output['") + std::string(names[I]) + "']";
|
|
||||||
} else {
|
|
||||||
return "output[" + std::to_string(I) + "]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Obj& obj_;
|
|
||||||
std::string name_;
|
|
||||||
std::size_t fifo_capacity_;
|
|
||||||
input_channels_t input_channels_;
|
|
||||||
output_channels_t output_channels_{};
|
|
||||||
std::atomic<bool> stop_flag_{false};
|
|
||||||
std::jthread thread_;
|
|
||||||
NodeStats stats_;
|
|
||||||
NodeErrorHandler error_handler_;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── make_node overloads for callable objects ──────────────────────────────────
|
// ── make_node overloads for callable objects ──────────────────────────────────
|
||||||
@ -569,35 +100,24 @@ auto make_node(Obj& obj, in<InNames...>, out<OutNames...>, std::size_t fifo_capa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── make_node factory (NTTP) ──────────────────────────────────────────────────
|
// ── make_node factory (NTTP) ──────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// make_node<func>(capacity)
|
|
||||||
// make_node<func, "label">(capacity)
|
|
||||||
// make_node<func, "label", 1>(capacity) // UniqueTag=1
|
|
||||||
// make_node<func>(in<"a","b">{}, capacity)
|
|
||||||
// make_node<func, "label">(in<"a","b">{}, out<"x">{}, capacity)
|
|
||||||
|
|
||||||
// No port names
|
|
||||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
|
||||||
auto make_node(std::size_t fifo_capacity = 5) {
|
auto make_node(std::size_t fifo_capacity = 5) {
|
||||||
return Node<Func, in<>, out<>, Label, UniqueTag>(fifo_capacity);
|
return Node<Func, in<>, out<>, Label, UniqueTag>(fifo_capacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// in<> only
|
|
||||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||||
fixed_string... InNames>
|
fixed_string... InNames>
|
||||||
auto make_node(in<InNames...>, std::size_t fifo_capacity = 5) {
|
auto make_node(in<InNames...>, std::size_t fifo_capacity = 5) {
|
||||||
return Node<Func, in<InNames...>, out<>, Label, UniqueTag>(fifo_capacity);
|
return Node<Func, in<InNames...>, out<>, Label, UniqueTag>(fifo_capacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// out<> only
|
|
||||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||||
fixed_string... OutNames>
|
fixed_string... OutNames>
|
||||||
auto make_node(out<OutNames...>, std::size_t fifo_capacity = 5) {
|
auto make_node(out<OutNames...>, std::size_t fifo_capacity = 5) {
|
||||||
return Node<Func, in<>, out<OutNames...>, Label, UniqueTag>(fifo_capacity);
|
return Node<Func, in<>, out<OutNames...>, Label, UniqueTag>(fifo_capacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// in<> and out<>
|
|
||||||
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||||
fixed_string... InNames, fixed_string... OutNames>
|
fixed_string... InNames, fixed_string... OutNames>
|
||||||
auto make_node(in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {
|
auto make_node(in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {
|
||||||
|
|||||||
696
include/kpn/pool_node.hpp
Normal file
696
include/kpn/pool_node.hpp
Normal file
@ -0,0 +1,696 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "channel.hpp"
|
||||||
|
#include "diagnostics.hpp"
|
||||||
|
#include "fixed_string.hpp"
|
||||||
|
#include "inode.hpp"
|
||||||
|
#include "port.hpp"
|
||||||
|
#include "scheduler.hpp"
|
||||||
|
#include "traits.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <iostream>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <thread>
|
||||||
|
#include <tuple>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
namespace kpn {
|
||||||
|
|
||||||
|
// ── PoolNode ──────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Reactive alternative to Node<>. Instead of owning a blocked thread, the node
|
||||||
|
// is submitted to a shared IScheduler whenever all its input channels become
|
||||||
|
// non-empty. A single fire_once() call pops all inputs, executes the function,
|
||||||
|
// and pushes outputs. At most one fire_once() runs at a time (queued_ flag).
|
||||||
|
//
|
||||||
|
// Source nodes (input_count == 0) submit themselves immediately on start() and
|
||||||
|
// resubmit after each fire_once().
|
||||||
|
//
|
||||||
|
// Multiple PoolNodes can share one ThreadPool for resource-bounded execution,
|
||||||
|
// or each can have a dedicated single-thread pool for serialisation.
|
||||||
|
|
||||||
|
template<auto Func,
|
||||||
|
typename InputTag = in<>,
|
||||||
|
typename OutputTag = out<>,
|
||||||
|
fixed_string Label = "",
|
||||||
|
std::size_t UniqueTag = 0>
|
||||||
|
class PoolNode;
|
||||||
|
|
||||||
|
template<auto Func, fixed_string... InNames, fixed_string... OutNames,
|
||||||
|
fixed_string Label, std::size_t UniqueTag>
|
||||||
|
class PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
|
||||||
|
public:
|
||||||
|
using F = decltype(Func);
|
||||||
|
using args_tuple = args_t<F>;
|
||||||
|
using return_raw = return_t<F>;
|
||||||
|
using return_tuple = normalised_return_t<return_raw>;
|
||||||
|
|
||||||
|
static constexpr std::string_view label() { return Label.view(); }
|
||||||
|
static constexpr std::size_t unique_tag = UniqueTag;
|
||||||
|
|
||||||
|
static constexpr std::size_t input_count = arity_v<F>;
|
||||||
|
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
||||||
|
|
||||||
|
static_assert(
|
||||||
|
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
||||||
|
"make_pool_node: number of input names must match function arity, or provide none"
|
||||||
|
);
|
||||||
|
static_assert(
|
||||||
|
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
||||||
|
"make_pool_node: number of output names must match return tuple size, or provide none"
|
||||||
|
);
|
||||||
|
|
||||||
|
explicit PoolNode(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5)
|
||||||
|
: scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
|
||||||
|
{
|
||||||
|
init_input_channels(std::make_index_sequence<input_count>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
~PoolNode() override { stop(); }
|
||||||
|
|
||||||
|
// ── INode ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void start() override {
|
||||||
|
enable_inputs(std::make_index_sequence<input_count>{});
|
||||||
|
stop_flag_.store(false, std::memory_order_relaxed);
|
||||||
|
queued_.store(false, std::memory_order_relaxed);
|
||||||
|
register_callbacks(std::make_index_sequence<input_count>{});
|
||||||
|
if constexpr (input_count == 0)
|
||||||
|
try_submit(0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() override {
|
||||||
|
stop_flag_.store(true, std::memory_order_seq_cst);
|
||||||
|
disable_inputs(std::make_index_sequence<input_count>{});
|
||||||
|
// fire_once() observes stop_flag_ and will not resubmit.
|
||||||
|
// We do not wait for an in-flight fire_once() to complete here;
|
||||||
|
// callers that need that guarantee should call scheduler_->drain() first.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool running() const override {
|
||||||
|
return !stop_flag_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_name(std::string name) override { name_ = std::move(name); }
|
||||||
|
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||||
|
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
|
||||||
|
|
||||||
|
const NodeStats& stats() const override { return stats_; }
|
||||||
|
|
||||||
|
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||||
|
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||||
|
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double qwait_ms = stats_.queue_wait_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double total_ms = exec_ms + blocked_ms;
|
||||||
|
return {
|
||||||
|
name, frames, exec_ms,
|
||||||
|
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
blocked_ms,
|
||||||
|
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||||
|
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||||
|
qwait_ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port access — by index ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
InputPort<PoolNode, I> input() {
|
||||||
|
static_assert(I < input_count, "input index out of range");
|
||||||
|
return {*this};
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
OutputPort<PoolNode, I> output() {
|
||||||
|
static_assert(I < output_count, "output index out of range");
|
||||||
|
return {*this};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port access — by name ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<fixed_string Name>
|
||||||
|
auto input() {
|
||||||
|
constexpr std::size_t idx = index_of<Name, InNames...>();
|
||||||
|
static_assert(idx != npos, "unknown input port name");
|
||||||
|
return input<idx>();
|
||||||
|
}
|
||||||
|
|
||||||
|
template<fixed_string Name>
|
||||||
|
auto output() {
|
||||||
|
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
||||||
|
static_assert(idx != npos, "unknown output port name");
|
||||||
|
return output<idx>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal channel accessors ────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
|
||||||
|
return *std::get<I>(input_channels_);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_input_channel(
|
||||||
|
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
|
||||||
|
std::get<I>(input_channels_) = std::move(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_output_channel(
|
||||||
|
Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
||||||
|
std::get<I>(output_channels_) = ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// ── Channel storage ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void init_input_channels(std::index_sequence<Is...>) {
|
||||||
|
((std::get<Is>(input_channels_) =
|
||||||
|
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
|
||||||
|
...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void enable_inputs(std::index_sequence<Is...>) {
|
||||||
|
(std::get<Is>(input_channels_)->enable(), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void disable_inputs(std::index_sequence<Is...>) {
|
||||||
|
(std::get<Is>(input_channels_)->disable(), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void register_callbacks(std::index_sequence<Is...>) {
|
||||||
|
(std::get<Is>(input_channels_)->set_push_callback(
|
||||||
|
[this] { on_input_ready(); }), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Tup, std::size_t... Is>
|
||||||
|
static auto make_input_channel_tuple(std::index_sequence<Is...>)
|
||||||
|
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
|
||||||
|
|
||||||
|
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
|
||||||
|
std::make_index_sequence<input_count>{}));
|
||||||
|
|
||||||
|
template<typename Tup, std::size_t... Is>
|
||||||
|
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
||||||
|
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
||||||
|
|
||||||
|
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||||
|
std::make_index_sequence<output_count>{}));
|
||||||
|
|
||||||
|
// ── Scheduling ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Called by channel push_callbacks (on the producer's thread).
|
||||||
|
void on_input_ready() {
|
||||||
|
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||||
|
std::size_t ready = count_ready(std::make_index_sequence<input_count>{});
|
||||||
|
if (ready == input_count)
|
||||||
|
try_submit(compute_priority());
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
std::size_t count_ready(std::index_sequence<Is...>) {
|
||||||
|
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
float compute_priority() {
|
||||||
|
if constexpr (input_count == 0) return 0.5f;
|
||||||
|
float sum = 0.0f;
|
||||||
|
sum_fill(sum, std::make_index_sequence<input_count>{});
|
||||||
|
return sum / static_cast<float>(input_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void sum_fill(float& sum, std::index_sequence<Is...>) {
|
||||||
|
((sum += std::get<Is>(input_channels_)->capacity() > 0
|
||||||
|
? float(std::get<Is>(input_channels_)->approx_size())
|
||||||
|
/ float(std::get<Is>(input_channels_)->capacity())
|
||||||
|
: 0.5f), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
void try_submit(float priority) {
|
||||||
|
bool expected = false;
|
||||||
|
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||||
|
scheduler_->submit([this] { fire_once(); }, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execution ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void fire_once() {
|
||||||
|
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record queue wait time (submission → now) and mark as executing
|
||||||
|
auto t0 = clock_t::now();
|
||||||
|
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||||
|
t0.time_since_epoch()).count();
|
||||||
|
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
try {
|
||||||
|
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
||||||
|
auto t1 = clock_t::now();
|
||||||
|
stats_.record_queue_wait(duration_t(t1 - t0));
|
||||||
|
auto cpu0 = NodeStats::cpu_now();
|
||||||
|
|
||||||
|
if constexpr (std::is_void_v<return_raw>) {
|
||||||
|
std::apply(Func, args);
|
||||||
|
} else {
|
||||||
|
auto result = std::apply(Func, args);
|
||||||
|
push_outputs(normalise(std::move(result)),
|
||||||
|
std::make_index_sequence<output_count>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
auto cpu1 = NodeStats::cpu_now();
|
||||||
|
auto t2 = clock_t::now();
|
||||||
|
// blocked_time = 0 for pool nodes (we don't block waiting for inputs)
|
||||||
|
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||||
|
} catch (const ChannelClosedError&) {
|
||||||
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
|
return;
|
||||||
|
} catch (const ChannelOverflowError& e) {
|
||||||
|
std::cerr << "[kpn] pool overflow: " << e.what() << "\n";
|
||||||
|
} catch (...) {
|
||||||
|
if (error_handler_ && error_handler_(name_, std::current_exception())) {
|
||||||
|
// continue — fall through to resubmit check
|
||||||
|
} else {
|
||||||
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
|
||||||
|
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||||
|
|
||||||
|
// Source nodes always resubmit; others resubmit only if inputs are ready.
|
||||||
|
if constexpr (input_count == 0) {
|
||||||
|
try_submit(0.5f);
|
||||||
|
} else {
|
||||||
|
on_input_ready();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop all inputs — safe because we're the sole consumer and fire_once
|
||||||
|
// is guarded by queued_ (only one fire_once runs at a time).
|
||||||
|
template<std::size_t... Is>
|
||||||
|
args_tuple pop_inputs(std::index_sequence<Is...>) {
|
||||||
|
return {pop_one<Is>()...};
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
std::tuple_element_t<I, args_tuple> pop_one() {
|
||||||
|
auto& ch = *std::get<I>(input_channels_);
|
||||||
|
std::tuple_element_t<I, args_tuple> val;
|
||||||
|
if (!ch.try_pop_now(val))
|
||||||
|
throw ChannelClosedError{};
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename R = return_raw>
|
||||||
|
static return_tuple normalise(R&& r) {
|
||||||
|
if constexpr (is_tuple_v<R>) return std::move(r);
|
||||||
|
else return std::make_tuple(std::move(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||||
|
(push_one_out<Is>(std::get<Is>(std::move(result))), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
|
||||||
|
auto* ch = std::get<I>(output_channels_);
|
||||||
|
if (!ch) return;
|
||||||
|
try {
|
||||||
|
ch->push(std::move(val));
|
||||||
|
} catch (const ChannelOverflowError&) {
|
||||||
|
throw ChannelOverflowError(ch->capacity(),
|
||||||
|
"pool node '" + name_ + "' " + output_port_label<I>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
static std::string output_port_label() {
|
||||||
|
if constexpr (sizeof...(OutNames) > 0) {
|
||||||
|
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
|
||||||
|
return std::string("output['") + std::string(names[I]) + "']";
|
||||||
|
} else {
|
||||||
|
return "output[" + std::to_string(I) + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
std::shared_ptr<IScheduler> scheduler_;
|
||||||
|
std::string name_;
|
||||||
|
std::size_t fifo_capacity_;
|
||||||
|
input_channels_t input_channels_;
|
||||||
|
output_channels_t output_channels_{};
|
||||||
|
std::atomic<bool> stop_flag_{true};
|
||||||
|
std::atomic<bool> queued_{false};
|
||||||
|
NodeStats stats_;
|
||||||
|
NodeErrorHandler error_handler_;
|
||||||
|
std::chrono::milliseconds max_exec_time_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── PoolObjectNode ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Same as PoolNode but wraps a stateful callable object (functor / class with
|
||||||
|
// operator()). The object must outlive the PoolObjectNode.
|
||||||
|
|
||||||
|
template<typename Obj,
|
||||||
|
typename InputTag = in<>,
|
||||||
|
typename OutputTag = out<>,
|
||||||
|
fixed_string Label = "",
|
||||||
|
std::size_t UniqueTag = 0>
|
||||||
|
class PoolObjectNode;
|
||||||
|
|
||||||
|
template<typename Obj, fixed_string... InNames, fixed_string... OutNames,
|
||||||
|
fixed_string Label, std::size_t UniqueTag>
|
||||||
|
class PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
|
||||||
|
public:
|
||||||
|
using F = decltype(&Obj::operator());
|
||||||
|
using args_tuple = args_t<F>;
|
||||||
|
using return_raw = return_t<F>;
|
||||||
|
using return_tuple = normalised_return_t<return_raw>;
|
||||||
|
|
||||||
|
static constexpr std::string_view label() { return Label.view(); }
|
||||||
|
static constexpr std::size_t unique_tag = UniqueTag;
|
||||||
|
|
||||||
|
static constexpr std::size_t input_count = arity_v<F>;
|
||||||
|
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
|
||||||
|
|
||||||
|
static_assert(
|
||||||
|
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
|
||||||
|
"make_pool_node: number of input names must match operator() arity, or provide none"
|
||||||
|
);
|
||||||
|
static_assert(
|
||||||
|
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
|
||||||
|
"make_pool_node: number of output names must match return tuple size, or provide none"
|
||||||
|
);
|
||||||
|
|
||||||
|
explicit PoolObjectNode(Obj& obj, std::shared_ptr<IScheduler> sched,
|
||||||
|
std::size_t fifo_capacity = 5)
|
||||||
|
: obj_(obj), scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
|
||||||
|
{
|
||||||
|
init_input_channels(std::make_index_sequence<input_count>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
~PoolObjectNode() override { stop(); }
|
||||||
|
|
||||||
|
void start() override {
|
||||||
|
enable_inputs(std::make_index_sequence<input_count>{});
|
||||||
|
stop_flag_.store(false, std::memory_order_relaxed);
|
||||||
|
queued_.store(false, std::memory_order_relaxed);
|
||||||
|
register_callbacks(std::make_index_sequence<input_count>{});
|
||||||
|
if constexpr (input_count == 0)
|
||||||
|
try_submit(0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() override {
|
||||||
|
stop_flag_.store(true, std::memory_order_seq_cst);
|
||||||
|
disable_inputs(std::make_index_sequence<input_count>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool running() const override { return !stop_flag_.load(std::memory_order_relaxed); }
|
||||||
|
void set_name(std::string name) override { name_ = std::move(name); }
|
||||||
|
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||||
|
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
|
||||||
|
|
||||||
|
const NodeStats& stats() const override { return stats_; }
|
||||||
|
|
||||||
|
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||||
|
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||||
|
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double qwait_ms = stats_.queue_wait_us.load(std::memory_order_relaxed) / 1000.0;
|
||||||
|
double total_ms = exec_ms + blocked_ms;
|
||||||
|
return {
|
||||||
|
name, frames, exec_ms,
|
||||||
|
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
blocked_ms,
|
||||||
|
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||||
|
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||||
|
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||||
|
qwait_ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I> InputPort<PoolObjectNode, I> input() { return {*this}; }
|
||||||
|
template<std::size_t I> OutputPort<PoolObjectNode, I> output() { return {*this}; }
|
||||||
|
|
||||||
|
template<fixed_string Name>
|
||||||
|
auto input() {
|
||||||
|
constexpr std::size_t idx = index_of<Name, InNames...>();
|
||||||
|
static_assert(idx != npos, "unknown input port name");
|
||||||
|
return input<idx>();
|
||||||
|
}
|
||||||
|
template<fixed_string Name>
|
||||||
|
auto output() {
|
||||||
|
constexpr std::size_t idx = index_of<Name, OutNames...>();
|
||||||
|
static_assert(idx != npos, "unknown output port name");
|
||||||
|
return output<idx>();
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
|
||||||
|
return *std::get<I>(input_channels_);
|
||||||
|
}
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_input_channel(std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
|
||||||
|
std::get<I>(input_channels_) = std::move(ch);
|
||||||
|
}
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
|
||||||
|
std::get<I>(output_channels_) = ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void init_input_channels(std::index_sequence<Is...>) {
|
||||||
|
((std::get<Is>(input_channels_) =
|
||||||
|
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
|
||||||
|
...);
|
||||||
|
}
|
||||||
|
template<std::size_t... Is> void enable_inputs(std::index_sequence<Is...>) { (std::get<Is>(input_channels_)->enable(), ...); }
|
||||||
|
template<std::size_t... Is> void disable_inputs(std::index_sequence<Is...>) { (std::get<Is>(input_channels_)->disable(), ...); }
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void register_callbacks(std::index_sequence<Is...>) {
|
||||||
|
(std::get<Is>(input_channels_)->set_push_callback([this] { on_input_ready(); }), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Tup, std::size_t... Is>
|
||||||
|
static auto make_input_channel_tuple(std::index_sequence<Is...>)
|
||||||
|
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
|
||||||
|
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
|
||||||
|
std::make_index_sequence<input_count>{}));
|
||||||
|
|
||||||
|
template<typename Tup, std::size_t... Is>
|
||||||
|
static auto make_output_channel_tuple(std::index_sequence<Is...>)
|
||||||
|
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
|
||||||
|
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||||
|
std::make_index_sequence<output_count>{}));
|
||||||
|
|
||||||
|
void on_input_ready() {
|
||||||
|
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||||
|
std::size_t ready = count_ready(std::make_index_sequence<input_count>{});
|
||||||
|
if (ready == input_count) try_submit(compute_priority());
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
std::size_t count_ready(std::index_sequence<Is...>) {
|
||||||
|
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
float compute_priority() {
|
||||||
|
if constexpr (input_count == 0) return 0.5f;
|
||||||
|
float sum = 0.0f;
|
||||||
|
sum_fill(sum, std::make_index_sequence<input_count>{});
|
||||||
|
return sum / static_cast<float>(input_count);
|
||||||
|
}
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void sum_fill(float& sum, std::index_sequence<Is...>) {
|
||||||
|
((sum += std::get<Is>(input_channels_)->capacity() > 0
|
||||||
|
? float(std::get<Is>(input_channels_)->approx_size())
|
||||||
|
/ float(std::get<Is>(input_channels_)->capacity())
|
||||||
|
: 0.5f), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
void try_submit(float priority) {
|
||||||
|
bool expected = false;
|
||||||
|
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||||
|
scheduler_->submit([this] { fire_once(); }, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fire_once() {
|
||||||
|
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto t0 = clock_t::now();
|
||||||
|
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||||
|
t0.time_since_epoch()).count();
|
||||||
|
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
try {
|
||||||
|
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
||||||
|
auto t1 = clock_t::now();
|
||||||
|
stats_.record_queue_wait(duration_t(t1 - t0));
|
||||||
|
auto cpu0 = NodeStats::cpu_now();
|
||||||
|
|
||||||
|
if constexpr (std::is_void_v<return_raw>) {
|
||||||
|
std::apply([this](auto&&... a) { obj_(std::forward<decltype(a)>(a)...); }, args);
|
||||||
|
} else {
|
||||||
|
auto result = std::apply([this](auto&&... a) { return obj_(std::forward<decltype(a)>(a)...); }, args);
|
||||||
|
push_outputs(normalise(std::move(result)), std::make_index_sequence<output_count>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
auto cpu1 = NodeStats::cpu_now();
|
||||||
|
auto t2 = clock_t::now();
|
||||||
|
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||||
|
} catch (const ChannelClosedError&) {
|
||||||
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
|
return;
|
||||||
|
} catch (const ChannelOverflowError& e) {
|
||||||
|
std::cerr << "[kpn] pool overflow: " << e.what() << "\n";
|
||||||
|
} catch (...) {
|
||||||
|
if (error_handler_ && error_handler_(name_, std::current_exception())) {
|
||||||
|
} else {
|
||||||
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||||
|
if constexpr (input_count == 0) try_submit(0.5f);
|
||||||
|
else on_input_ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
args_tuple pop_inputs(std::index_sequence<Is...>) { return {pop_one<Is>()...}; }
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
std::tuple_element_t<I, args_tuple> pop_one() {
|
||||||
|
auto& ch = *std::get<I>(input_channels_);
|
||||||
|
std::tuple_element_t<I, args_tuple> val;
|
||||||
|
if (!ch.try_pop_now(val)) throw ChannelClosedError{};
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename R = return_raw>
|
||||||
|
static return_tuple normalise(R&& r) {
|
||||||
|
if constexpr (is_tuple_v<R>) return std::move(r);
|
||||||
|
else return std::make_tuple(std::move(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||||
|
(push_one_out<Is>(std::get<Is>(std::move(result))), ...);
|
||||||
|
}
|
||||||
|
template<std::size_t I>
|
||||||
|
void push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
|
||||||
|
auto* ch = std::get<I>(output_channels_);
|
||||||
|
if (!ch) return;
|
||||||
|
try {
|
||||||
|
ch->push(std::move(val));
|
||||||
|
} catch (const ChannelOverflowError&) {
|
||||||
|
throw ChannelOverflowError(ch->capacity(),
|
||||||
|
"pool node '" + name_ + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Obj& obj_;
|
||||||
|
std::shared_ptr<IScheduler> scheduler_;
|
||||||
|
std::string name_;
|
||||||
|
std::size_t fifo_capacity_;
|
||||||
|
input_channels_t input_channels_;
|
||||||
|
output_channels_t output_channels_{};
|
||||||
|
std::atomic<bool> stop_flag_{true};
|
||||||
|
std::atomic<bool> queued_{false};
|
||||||
|
NodeStats stats_;
|
||||||
|
NodeErrorHandler error_handler_;
|
||||||
|
std::chrono::milliseconds max_exec_time_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── make_pool_node factory (NTTP) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
|
||||||
|
auto make_pool_node(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5) {
|
||||||
|
return PoolNode<Func, in<>, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||||
|
fixed_string... InNames>
|
||||||
|
auto make_pool_node(std::shared_ptr<IScheduler> sched, in<InNames...>,
|
||||||
|
std::size_t fifo_capacity = 5) {
|
||||||
|
return PoolNode<Func, in<InNames...>, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||||
|
fixed_string... OutNames>
|
||||||
|
auto make_pool_node(std::shared_ptr<IScheduler> sched, out<OutNames...>,
|
||||||
|
std::size_t fifo_capacity = 5) {
|
||||||
|
return PoolNode<Func, in<>, out<OutNames...>, Label, UniqueTag>(std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
|
||||||
|
fixed_string... InNames, fixed_string... OutNames>
|
||||||
|
auto make_pool_node(std::shared_ptr<IScheduler> sched, in<InNames...>, out<OutNames...>,
|
||||||
|
std::size_t fifo_capacity = 5) {
|
||||||
|
return PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>(
|
||||||
|
std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── make_pool_node factory (callable object) ──────────────────────────────────
|
||||||
|
|
||||||
|
template<typename Obj>
|
||||||
|
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched,
|
||||||
|
std::size_t fifo_capacity = 5) {
|
||||||
|
return PoolObjectNode<Obj, in<>, out<>>(obj, std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Obj, fixed_string... InNames>
|
||||||
|
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched, in<InNames...>,
|
||||||
|
std::size_t fifo_capacity = 5) {
|
||||||
|
return PoolObjectNode<Obj, in<InNames...>, out<>>(obj, std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Obj, fixed_string... OutNames>
|
||||||
|
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched, out<OutNames...>,
|
||||||
|
std::size_t fifo_capacity = 5) {
|
||||||
|
return PoolObjectNode<Obj, in<>, out<OutNames...>>(obj, std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Obj, fixed_string... InNames, fixed_string... OutNames>
|
||||||
|
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched,
|
||||||
|
in<InNames...>, out<OutNames...>,
|
||||||
|
std::size_t fifo_capacity = 5) {
|
||||||
|
return PoolObjectNode<Obj, in<InNames...>, out<OutNames...>>(
|
||||||
|
obj, std::move(sched), fifo_capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace kpn
|
||||||
311
include/kpn/python/auto_bind.hpp
Normal file
311
include/kpn/python/auto_bind.hpp
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
#pragma once
|
||||||
|
// Auto-binding helpers for KPN++ Python bindings.
|
||||||
|
//
|
||||||
|
// Usage in your binding .cpp:
|
||||||
|
//
|
||||||
|
// #define KPN_BUILD_PYTHON
|
||||||
|
// #include <kpn/python/auto_bind.hpp>
|
||||||
|
//
|
||||||
|
// int produce() { return 42; }
|
||||||
|
// int double_it(int x) { return x * 2; }
|
||||||
|
// void print_it(int x) { std::cout << x << '\n'; }
|
||||||
|
//
|
||||||
|
// using MyNodes = kpn::python::NodeRegistry<
|
||||||
|
// kpn::python::Entry<produce, "produce">,
|
||||||
|
// kpn::python::Entry<double_it, "double_it">,
|
||||||
|
// kpn::python::Entry<print_it, "print_it">
|
||||||
|
// >;
|
||||||
|
//
|
||||||
|
// NB_MODULE(my_kpn, m) {
|
||||||
|
// kpn::python::bind_network<MyNodes>(m); // KPN_BIND_PYTHON behaviour
|
||||||
|
// kpn::python::bind_debug<MyNodes>(m); // KPN_PYTHON_DEBUG behaviour
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// bind_network registers:
|
||||||
|
// - Network class (PyNetwork<auto-deduced-variant>) with auto-registered converters
|
||||||
|
// - make_<name>(capacity=5) factory for each entry
|
||||||
|
// - <Name>Node class for each entry
|
||||||
|
//
|
||||||
|
// bind_debug additionally registers each raw C++ function as a free Python
|
||||||
|
// callable (e.g. double_it(5) → 10) so node logic can be tested without a network.
|
||||||
|
//
|
||||||
|
// To support a custom type T, specialise kpn::PythonConverter<T> before calling
|
||||||
|
// bind_network:
|
||||||
|
//
|
||||||
|
// namespace kpn {
|
||||||
|
// template<> struct PythonConverter<MyVec3> {
|
||||||
|
// static constexpr const char* type_name = "vec3"; // optional friendly name
|
||||||
|
// static nb::object to_python(const MyVec3& v) { ... }
|
||||||
|
// static MyVec3 from_python(nb::object o) { ... }
|
||||||
|
// };
|
||||||
|
// } // namespace kpn
|
||||||
|
|
||||||
|
#include "../variant_node.hpp"
|
||||||
|
#include "../traits.hpp"
|
||||||
|
#include "bindings.hpp"
|
||||||
|
|
||||||
|
#ifdef KPN_BUILD_PYTHON
|
||||||
|
#include <nanobind/nanobind.h>
|
||||||
|
#include <nanobind/stl/shared_ptr.h>
|
||||||
|
#include <nanobind/stl/string.h>
|
||||||
|
#include <nanobind/stl/vector.h>
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
#include <string>
|
||||||
|
#include <tuple>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
// ── PythonConverter specialisations for built-in nanobind-castable types ─────
|
||||||
|
// These live in kpn:: to match the primary template in variant_node.hpp.
|
||||||
|
|
||||||
|
namespace kpn {
|
||||||
|
|
||||||
|
template<> struct PythonConverter<int> {
|
||||||
|
static constexpr const char* type_name = "int";
|
||||||
|
static nanobind::object to_python(const int& v) { return nanobind::cast(v); }
|
||||||
|
static int from_python(nanobind::object o) { return nanobind::cast<int>(std::move(o)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
template<> struct PythonConverter<float> {
|
||||||
|
static constexpr const char* type_name = "float";
|
||||||
|
static nanobind::object to_python(const float& v) { return nanobind::cast(v); }
|
||||||
|
static float from_python(nanobind::object o) { return nanobind::cast<float>(std::move(o)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
template<> struct PythonConverter<double> {
|
||||||
|
static constexpr const char* type_name = "double";
|
||||||
|
static nanobind::object to_python(const double& v) { return nanobind::cast(v); }
|
||||||
|
static double from_python(nanobind::object o) { return nanobind::cast<double>(std::move(o)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
template<> struct PythonConverter<bool> {
|
||||||
|
static constexpr const char* type_name = "bool";
|
||||||
|
static nanobind::object to_python(const bool& v) { return nanobind::cast(v); }
|
||||||
|
static bool from_python(nanobind::object o) { return nanobind::cast<bool>(std::move(o)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
template<> struct PythonConverter<std::string> {
|
||||||
|
static constexpr const char* type_name = "str";
|
||||||
|
static nanobind::object to_python(const std::string& v) { return nanobind::cast(v); }
|
||||||
|
static std::string from_python(nanobind::object o) {
|
||||||
|
return nanobind::cast<std::string>(std::move(o));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace kpn
|
||||||
|
|
||||||
|
namespace kpn::python {
|
||||||
|
namespace nb = nanobind;
|
||||||
|
|
||||||
|
// ── Entry<Func, Name> ─────────────────────────────────────────────────────────
|
||||||
|
// Compile-time descriptor for one bindable node function.
|
||||||
|
|
||||||
|
template<auto Func, fixed_string Name>
|
||||||
|
struct Entry {
|
||||||
|
static constexpr auto func = Func;
|
||||||
|
static constexpr auto name = Name;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── NodeRegistry<Es...> ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<typename... Es>
|
||||||
|
struct NodeRegistry {
|
||||||
|
using entries_tuple = std::tuple<Es...>;
|
||||||
|
static constexpr std::size_t size = sizeof...(Es);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Internal TMP ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
namespace detail {
|
||||||
|
|
||||||
|
// All non-void port types for a single function (args + normalised returns).
|
||||||
|
template<auto Func>
|
||||||
|
struct entry_port_types {
|
||||||
|
using args = args_t<decltype(Func)>;
|
||||||
|
using ret = normalised_return_t<return_t<decltype(Func)>>;
|
||||||
|
using type = decltype(std::tuple_cat(std::declval<args>(), std::declval<ret>()));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flat tuple of all types across all entries (may contain duplicates).
|
||||||
|
template<typename... Es>
|
||||||
|
struct all_types_flat {
|
||||||
|
using type = decltype(std::tuple_cat(
|
||||||
|
std::declval<typename entry_port_types<Es::func>::type>()...));
|
||||||
|
};
|
||||||
|
|
||||||
|
template<typename Registry>
|
||||||
|
struct registry_flat_types;
|
||||||
|
|
||||||
|
template<typename... Es>
|
||||||
|
struct registry_flat_types<NodeRegistry<Es...>> {
|
||||||
|
using type = typename all_types_flat<Es...>::type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unpack a tuple into unique_types_t (which takes a pack, not a tuple).
|
||||||
|
// unique_types_t<T> takes Ts... not std::tuple<Ts...>, so we need this bridge.
|
||||||
|
template<typename Tuple>
|
||||||
|
struct unpack_unique;
|
||||||
|
|
||||||
|
template<typename... Ts>
|
||||||
|
struct unpack_unique<std::tuple<Ts...>> {
|
||||||
|
using type = kpn::detail::unique_types_t<Ts...>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// SFINAE: does PythonConverter<T> have a 'type_name' member?
|
||||||
|
template<typename Conv, typename = void>
|
||||||
|
struct has_type_name : std::false_type {};
|
||||||
|
|
||||||
|
template<typename Conv>
|
||||||
|
struct has_type_name<Conv, std::void_t<decltype(Conv::type_name)>> : std::true_type {};
|
||||||
|
|
||||||
|
inline std::string make_class_name(std::string_view snake) {
|
||||||
|
std::string result(snake);
|
||||||
|
if (!result.empty()) result[0] = static_cast<char>(std::toupper(result[0]));
|
||||||
|
result += "Node";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace detail
|
||||||
|
|
||||||
|
// ── registry_variant_t<Registry> ─────────────────────────────────────────────
|
||||||
|
// Deduces std::variant<UniqueTypes...> from all port types across the registry.
|
||||||
|
|
||||||
|
template<typename Registry>
|
||||||
|
using registry_variant_t = typename kpn::detail::tuple_to_variant<
|
||||||
|
typename detail::unpack_unique<
|
||||||
|
typename detail::registry_flat_types<Registry>::type>::type
|
||||||
|
>::type;
|
||||||
|
|
||||||
|
// ── Converter registration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
template<typename T, typename Variant>
|
||||||
|
void register_one_type(PyNetwork<Variant>& net) {
|
||||||
|
const char* friendly = nullptr;
|
||||||
|
if constexpr (detail::has_type_name<PythonConverter<T>>::value)
|
||||||
|
friendly = PythonConverter<T>::type_name;
|
||||||
|
|
||||||
|
net.template register_full_type<T>(
|
||||||
|
[](const T& v) -> nb::object { return PythonConverter<T>::to_python(v); },
|
||||||
|
[](nb::object o) -> T { return PythonConverter<T>::from_python(std::move(o)); },
|
||||||
|
friendly);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Variant, typename... Ts>
|
||||||
|
void register_types_impl(PyNetwork<Variant>& net, std::tuple<Ts...>*) {
|
||||||
|
(register_one_type<Ts>(net), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Registry, typename Variant>
|
||||||
|
void register_all_converters(PyNetwork<Variant>& net) {
|
||||||
|
using Flat = typename detail::registry_flat_types<Registry>::type;
|
||||||
|
using Unique = typename detail::unpack_unique<Flat>::type;
|
||||||
|
register_types_impl(net, static_cast<Unique*>(nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-entry class + factory registration ───────────────────────────────────
|
||||||
|
|
||||||
|
namespace detail {
|
||||||
|
|
||||||
|
template<typename E, typename Variant>
|
||||||
|
void register_one_entry(nb::module_& m) {
|
||||||
|
using Wrapper = VariantNodeWrapper<E::func, Variant>;
|
||||||
|
|
||||||
|
auto class_name = make_class_name(E::name.view());
|
||||||
|
auto make_name = "make_" + std::string(E::name.view());
|
||||||
|
|
||||||
|
nb::class_<Wrapper, IVariantNode<Variant>>(m, class_name.c_str())
|
||||||
|
.def("__init__", [](Wrapper* self, std::size_t cap) {
|
||||||
|
new (self) Wrapper(cap);
|
||||||
|
}, nb::arg("capacity") = 5);
|
||||||
|
|
||||||
|
m.def(make_name.c_str(),
|
||||||
|
[](std::size_t cap) -> std::shared_ptr<IVariantNode<Variant>> {
|
||||||
|
return std::make_shared<Wrapper>(cap);
|
||||||
|
},
|
||||||
|
nb::arg("capacity") = 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Variant, typename... Es>
|
||||||
|
void register_entries_impl(nb::module_& m, std::tuple<Es...>*) {
|
||||||
|
(register_one_entry<Es, Variant>(m), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace detail
|
||||||
|
|
||||||
|
// ── bind_network<Registry> ────────────────────────────────────────────────────
|
||||||
|
// Registers:
|
||||||
|
// - INode — base class (opaque Python handle)
|
||||||
|
// - Network — PyNetwork with auto-registered converters
|
||||||
|
// - <Name>Node — VariantNodeWrapper for each entry
|
||||||
|
// - make_<name>() — factory returning shared_ptr<INode>
|
||||||
|
|
||||||
|
template<typename Registry>
|
||||||
|
void bind_network(nb::module_& m) {
|
||||||
|
using Variant = registry_variant_t<Registry>;
|
||||||
|
using Net = PyNetwork<Variant>;
|
||||||
|
using Entries = typename Registry::entries_tuple;
|
||||||
|
|
||||||
|
nb::class_<IVariantNode<Variant>>(m, "INode");
|
||||||
|
|
||||||
|
nb::class_<Net>(m, "Network")
|
||||||
|
.def("__init__", [](Net* self) {
|
||||||
|
new (self) Net();
|
||||||
|
register_all_converters<Registry>(*self);
|
||||||
|
})
|
||||||
|
// add(name, c++_node)
|
||||||
|
.def("add", [](Net& self, std::string name,
|
||||||
|
std::shared_ptr<IVariantNode<Variant>> node) {
|
||||||
|
self.add(std::move(name), std::move(node));
|
||||||
|
}, nb::arg("name"), nb::arg("node"))
|
||||||
|
// add_node(name, callable, inputs=[...], outputs=[...], capacity=5)
|
||||||
|
.def("add_node", &Net::add_node_python,
|
||||||
|
nb::arg("name"),
|
||||||
|
nb::arg("callable"),
|
||||||
|
nb::arg("inputs") = std::vector<std::string>{},
|
||||||
|
nb::arg("outputs") = std::vector<std::string>{},
|
||||||
|
nb::arg("capacity") = std::size_t(5))
|
||||||
|
.def("connect", &Net::connect,
|
||||||
|
nb::arg("src"), nb::arg("out_idx"),
|
||||||
|
nb::arg("dst"), nb::arg("in_idx"))
|
||||||
|
.def("build", &Net::build)
|
||||||
|
.def("start", &Net::start)
|
||||||
|
.def("stop", &Net::stop)
|
||||||
|
.def("read", &Net::read,
|
||||||
|
nb::arg("node"), nb::arg("out_idx") = std::size_t(0))
|
||||||
|
.def("write", &Net::write,
|
||||||
|
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"))
|
||||||
|
;
|
||||||
|
|
||||||
|
detail::register_entries_impl<Variant>(m, static_cast<Entries*>(nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── bind_debug<Registry> ─────────────────────────────────────────────────────
|
||||||
|
// Exposes each node's raw C++ function as a free Python callable so node logic
|
||||||
|
// can be unit-tested without constructing a network.
|
||||||
|
//
|
||||||
|
// Example: assert kpn.double_it(5) == 10
|
||||||
|
|
||||||
|
namespace detail {
|
||||||
|
|
||||||
|
template<typename E>
|
||||||
|
void bind_one_debug(nb::module_& m) {
|
||||||
|
auto name_str = std::string(E::name.view());
|
||||||
|
m.def(name_str.c_str(), E::func);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Es>
|
||||||
|
void bind_debug_impl(nb::module_& m, std::tuple<Es...>*) {
|
||||||
|
(bind_one_debug<Es>(m), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace detail
|
||||||
|
|
||||||
|
template<typename Registry>
|
||||||
|
void bind_debug(nb::module_& m) {
|
||||||
|
using Entries = typename Registry::entries_tuple;
|
||||||
|
detail::bind_debug_impl(m, static_cast<Entries*>(nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace kpn::python
|
||||||
|
|
||||||
|
#endif // KPN_BUILD_PYTHON
|
||||||
@ -26,6 +26,9 @@ namespace nb = nanobind;
|
|||||||
// them via IVariantChannel adapters. The variant only lives at the boundary;
|
// them via IVariantChannel adapters. The variant only lives at the boundary;
|
||||||
// each node's internal Channel<T> stores raw T values.
|
// each node's internal Channel<T> stores raw T values.
|
||||||
|
|
||||||
|
template<typename Variant>
|
||||||
|
class PyNode; // forward declaration
|
||||||
|
|
||||||
template<typename Variant>
|
template<typename Variant>
|
||||||
class PyNetwork {
|
class PyNetwork {
|
||||||
public:
|
public:
|
||||||
@ -43,8 +46,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// connect(src_name, out_idx, dst_name, in_idx)
|
// 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,
|
void connect(const std::string& src_name, std::size_t out_idx,
|
||||||
const std::string& dst_name, std::size_t in_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_name + ".input[" + std::to_string(in_idx) +
|
||||||
"] (" + dst.input_type(in_idx).name() + ")");
|
"] (" + 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);
|
auto ch = dst.input_channel(in_idx);
|
||||||
src.set_output_channel(out_idx, std::move(ch));
|
src.set_output_channel(out_idx, std::move(ch));
|
||||||
adj_[src_name].push_back(dst_name);
|
adj_[src_name].push_back(dst_name);
|
||||||
@ -92,16 +92,12 @@ public:
|
|||||||
|
|
||||||
// ── Python tap/inject ─────────────────────────────────────────────────────
|
// ── 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) {
|
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);
|
auto key = tap_key(node_name, out_idx);
|
||||||
if (!taps_.count(key)) {
|
if (!taps_.count(key)) {
|
||||||
auto& src = node_at(node_name);
|
auto& src = node_at(node_name);
|
||||||
if (out_idx >= src.output_count())
|
if (out_idx >= src.output_count())
|
||||||
throw std::out_of_range(node_name + ": output index out of range");
|
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));
|
auto tap = make_tap_channel(src.output_type(out_idx));
|
||||||
src.set_output_channel(out_idx, tap);
|
src.set_output_channel(out_idx, tap);
|
||||||
taps_[key] = std::move(tap);
|
taps_[key] = std::move(tap);
|
||||||
@ -114,7 +110,6 @@ public:
|
|||||||
return variant_to_python(std::move(v));
|
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) {
|
void write(const std::string& node_name, std::size_t in_idx, nb::object value) {
|
||||||
auto& dst = node_at(node_name);
|
auto& dst = node_at(node_name);
|
||||||
if (in_idx >= dst.input_count())
|
if (in_idx >= dst.input_count())
|
||||||
@ -128,8 +123,31 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Converter registration ────────────────────────────────────────────────
|
// ── Python-callable node creation ─────────────────────────────────────────
|
||||||
// Called once per type at module init time to register to/from Python converters.
|
// Creates a PyNode wrapping a Python callable and adds it to the graph.
|
||||||
|
// Type names must have been registered via register_full_type<T>().
|
||||||
|
|
||||||
|
void add_node_python(std::string name, nb::object callable,
|
||||||
|
std::vector<std::string> in_names,
|
||||||
|
std::vector<std::string> out_names,
|
||||||
|
std::size_t capacity = 5)
|
||||||
|
{
|
||||||
|
std::vector<std::type_index> in_types, out_types;
|
||||||
|
for (auto& s : in_names) in_types.push_back(resolve_type_name(s));
|
||||||
|
for (auto& s : out_names) out_types.push_back(resolve_type_name(s));
|
||||||
|
|
||||||
|
add(std::move(name),
|
||||||
|
std::make_shared<PyNode<Variant>>(
|
||||||
|
std::move(callable),
|
||||||
|
std::move(in_types),
|
||||||
|
std::move(out_types),
|
||||||
|
to_python_,
|
||||||
|
from_python_,
|
||||||
|
ch_factories_,
|
||||||
|
capacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Type converter registration ───────────────────────────────────────────
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
void register_type(
|
void register_type(
|
||||||
@ -143,6 +161,52 @@ public:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// register_channel_factory<T>: registers factory for creating input channels.
|
||||||
|
template<typename T>
|
||||||
|
void register_channel_factory() {
|
||||||
|
ch_factories_[std::type_index(typeid(T))] =
|
||||||
|
[](std::size_t cap) -> std::shared_ptr<VChannel> {
|
||||||
|
return std::make_shared<VariantChannel<T, Variant>>(
|
||||||
|
std::make_shared<Channel<T>>(cap));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible alias.
|
||||||
|
template<typename T>
|
||||||
|
void register_tap_factory(std::size_t = 5) {
|
||||||
|
register_channel_factory<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// register_full_type<T>: registers converters + channel factory + type name.
|
||||||
|
// This is what auto_bind.hpp calls; manual bindings can call register_type +
|
||||||
|
// register_tap_factory separately for backward compatibility.
|
||||||
|
template<typename T>
|
||||||
|
void register_full_type(
|
||||||
|
std::function<nb::object(const T&)> to_py,
|
||||||
|
std::function<T(nb::object)> from_py,
|
||||||
|
const char* friendly_name = nullptr)
|
||||||
|
{
|
||||||
|
register_type<T>(std::move(to_py), std::move(from_py));
|
||||||
|
register_channel_factory<T>();
|
||||||
|
auto idx = std::type_index(typeid(T));
|
||||||
|
type_names_.insert_or_assign(typeid(T).name(), idx);
|
||||||
|
if (friendly_name) type_names_.insert_or_assign(friendly_name, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Type name lookup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void register_type_name(const std::string& name, std::type_index idx) {
|
||||||
|
type_names_.insert_or_assign(name, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::type_index resolve_type_name(const std::string& name) const {
|
||||||
|
auto it = type_names_.find(name);
|
||||||
|
if (it == type_names_.end())
|
||||||
|
throw std::runtime_error(
|
||||||
|
"type '" + name + "' not registered — call register_full_type<T>() first");
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
VNode& node_at(const std::string& name) {
|
VNode& node_at(const std::string& name) {
|
||||||
auto it = nodes_.find(name);
|
auto it = nodes_.find(name);
|
||||||
@ -166,15 +230,14 @@ private:
|
|||||||
return node + ":" + std::to_string(idx);
|
return node + ":" + std::to_string(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::shared_ptr<VChannel> make_tap_channel(std::type_index type) {
|
std::shared_ptr<VChannel> make_tap_channel(std::type_index type,
|
||||||
// Create the right VariantChannel<T> based on the registered type index.
|
std::size_t cap = 5) {
|
||||||
// We need a factory registered per type — stored in tap_factories_.
|
auto it = ch_factories_.find(type);
|
||||||
auto it = tap_factories_.find(type);
|
if (it == ch_factories_.end())
|
||||||
if (it == tap_factories_.end())
|
|
||||||
throw std::runtime_error(
|
throw std::runtime_error(
|
||||||
"no tap factory for type: " + std::string(type.name()) +
|
"no channel factory for type: " + std::string(type.name()) +
|
||||||
" — was register_type() called for this type?");
|
" — call register_full_type<T>() or register_tap_factory<T>()");
|
||||||
return it->second();
|
return it->second(cap);
|
||||||
}
|
}
|
||||||
|
|
||||||
nb::object variant_to_python(Variant v) {
|
nb::object variant_to_python(Variant v) {
|
||||||
@ -194,18 +257,6 @@ private:
|
|||||||
return it->second(std::move(obj));
|
return it->second(std::move(obj));
|
||||||
}
|
}
|
||||||
|
|
||||||
public:
|
|
||||||
// Called by register_type to also register a tap channel factory.
|
|
||||||
template<typename T>
|
|
||||||
void register_tap_factory(std::size_t capacity = 5) {
|
|
||||||
auto idx = std::type_index(typeid(T));
|
|
||||||
tap_factories_[idx] = [capacity]() -> std::shared_ptr<VChannel> {
|
|
||||||
auto ch = std::make_shared<Channel<T>>(capacity);
|
|
||||||
return std::make_shared<VariantChannel<T, Variant>>(std::move(ch));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::map<std::string, std::shared_ptr<VNode>> nodes_;
|
std::map<std::string, std::shared_ptr<VNode>> nodes_;
|
||||||
std::map<std::string, std::vector<std::string>> adj_;
|
std::map<std::string, std::vector<std::string>> adj_;
|
||||||
std::vector<std::string> topo_;
|
std::vector<std::string> topo_;
|
||||||
@ -213,19 +264,27 @@ private:
|
|||||||
|
|
||||||
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
|
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
|
||||||
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
|
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
|
||||||
std::map<std::type_index, std::function<std::shared_ptr<VChannel>()>> tap_factories_;
|
|
||||||
|
// Channel factory: type → function(capacity) → VChannel.
|
||||||
|
// Used both for tap channels (read()) and PyNode input channel creation.
|
||||||
|
std::map<std::type_index,
|
||||||
|
std::function<std::shared_ptr<VChannel>(std::size_t)>> ch_factories_;
|
||||||
|
|
||||||
|
// Friendly name → type_index (e.g. "int" → typeid(int)).
|
||||||
|
std::map<std::string, std::type_index> type_names_;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── PyNode<Variant> ───────────────────────────────────────────────────────────
|
// ── PyNode<Variant> ───────────────────────────────────────────────────────────
|
||||||
// A pure-Python processing node. Holds a nanobind callable.
|
// A pure-Python processing node. Holds a nanobind callable.
|
||||||
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs (release GIL).
|
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs.
|
||||||
|
|
||||||
template<typename Variant>
|
template<typename Variant>
|
||||||
class PyNode : public IVariantNode<Variant> {
|
class PyNode : public IVariantNode<Variant> {
|
||||||
public:
|
public:
|
||||||
using VChannel = IVariantChannel<Variant>;
|
using VChannel = IVariantChannel<Variant>;
|
||||||
|
|
||||||
using ChannelFactory = std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
|
using ChannelFactory =
|
||||||
|
std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
|
||||||
|
|
||||||
PyNode(nb::object callable,
|
PyNode(nb::object callable,
|
||||||
std::vector<std::type_index> in_types,
|
std::vector<std::type_index> in_types,
|
||||||
@ -264,7 +323,6 @@ public:
|
|||||||
for (auto& ch : in_channels_) ch->disable();
|
for (auto& ch : in_channels_) ch->disable();
|
||||||
if (thread_.joinable()) {
|
if (thread_.joinable()) {
|
||||||
thread_.request_stop();
|
thread_.request_stop();
|
||||||
// Release GIL while joining — run_loop may be waiting to acquire it.
|
|
||||||
nb::gil_scoped_release release;
|
nb::gil_scoped_release release;
|
||||||
thread_.join();
|
thread_.join();
|
||||||
}
|
}
|
||||||
@ -311,12 +369,10 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
void run_loop() {
|
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)) {
|
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||||
try {
|
try {
|
||||||
auto t0 = clock_t::now();
|
auto t0 = clock_t::now();
|
||||||
|
|
||||||
// Pop all inputs — no GIL needed, these are pure C++ channel ops
|
|
||||||
std::vector<Variant> inputs(in_channels_.size());
|
std::vector<Variant> inputs(in_channels_.size());
|
||||||
for (std::size_t i = 0; i < in_channels_.size(); ++i)
|
for (std::size_t i = 0; i < in_channels_.size(); ++i)
|
||||||
inputs[i] = in_channels_[i]->pop();
|
inputs[i] = in_channels_[i]->pop();
|
||||||
@ -324,7 +380,6 @@ private:
|
|||||||
auto t1 = clock_t::now();
|
auto t1 = clock_t::now();
|
||||||
auto cpu0 = NodeStats::cpu_now();
|
auto cpu0 = NodeStats::cpu_now();
|
||||||
|
|
||||||
// Acquire GIL only for the Python call and type conversion
|
|
||||||
std::vector<Variant> outputs;
|
std::vector<Variant> outputs;
|
||||||
{
|
{
|
||||||
nb::gil_scoped_acquire acquire;
|
nb::gil_scoped_acquire acquire;
|
||||||
@ -347,7 +402,6 @@ private:
|
|||||||
auto t2 = clock_t::now();
|
auto t2 = clock_t::now();
|
||||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
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) {
|
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
|
||||||
if (out_channels_[i])
|
if (out_channels_[i])
|
||||||
out_channels_[i]->push(std::move(outputs[i]));
|
out_channels_[i]->push(std::move(outputs[i]));
|
||||||
@ -385,9 +439,9 @@ private:
|
|||||||
NodeStats stats_;
|
NodeStats stats_;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── register_py_network ───────────────────────────────────────────────────────
|
// ── register_py_network (legacy helper) ───────────────────────────────────────
|
||||||
// Registers PyNetwork<Variant> and PyNode<Variant> with the given nanobind module.
|
// Registers PyNetwork<Variant> with the given nanobind module.
|
||||||
// Call once per module, passing the Variant type derived from your registered node types.
|
// Prefer bind_network<Registry> from auto_bind.hpp for new code.
|
||||||
|
|
||||||
template<typename Variant>
|
template<typename Variant>
|
||||||
void register_py_network(nb::module_& m, const char* class_name = "Network") {
|
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("start", &Net::start)
|
||||||
.def("stop", &Net::stop)
|
.def("stop", &Net::stop)
|
||||||
.def("read", &Net::read,
|
.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,
|
.def("write", &Net::write,
|
||||||
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
|
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
|
||||||
}
|
}
|
||||||
|
|||||||
198
include/kpn/scheduler.hpp
Normal file
198
include/kpn/scheduler.hpp
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "diagnostics.hpp"
|
||||||
|
#include <atomic>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <queue>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace kpn {
|
||||||
|
|
||||||
|
// ── IScheduler ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct IScheduler {
|
||||||
|
virtual ~IScheduler() = default;
|
||||||
|
|
||||||
|
// Submit a task with an optional priority in [0, 1]. Higher = run sooner.
|
||||||
|
virtual void submit(std::function<void()> task, float priority = 0.5f) = 0;
|
||||||
|
|
||||||
|
// Start worker threads. Must be called before submit().
|
||||||
|
virtual void start() = 0;
|
||||||
|
|
||||||
|
// Halt: signal workers to exit and join them. Pending tasks are discarded.
|
||||||
|
virtual void stop() = 0;
|
||||||
|
|
||||||
|
// Drain: block until all in-flight tasks complete. Workers keep running.
|
||||||
|
virtual void drain() = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── ThreadPool ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Work-stealing thread pool with per-thread priority queues.
|
||||||
|
//
|
||||||
|
// Each worker owns a priority_queue (max-heap by priority, FIFO within equal
|
||||||
|
// priority via sequence number). submit() distributes via round-robin. When a
|
||||||
|
// worker's queue is empty it tries to steal from the most-loaded peer using
|
||||||
|
// try_lock to avoid blocking; if no work is found it sleeps on a shared CV.
|
||||||
|
//
|
||||||
|
// total_ counts tasks submitted-but-not-completed (queued + executing).
|
||||||
|
// drain() waits until total_ == 0.
|
||||||
|
|
||||||
|
class ThreadPool : public IScheduler, public IPoolProbe {
|
||||||
|
public:
|
||||||
|
explicit ThreadPool(std::size_t thread_count) : thread_count_(thread_count) {}
|
||||||
|
|
||||||
|
~ThreadPool() {
|
||||||
|
if (!stopped_.load(std::memory_order_relaxed))
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() override {
|
||||||
|
stopped_.store(false, std::memory_order_relaxed);
|
||||||
|
queues_.clear();
|
||||||
|
for (std::size_t i = 0; i < thread_count_; ++i)
|
||||||
|
queues_.push_back(std::make_unique<WorkerQueue>());
|
||||||
|
workers_.reserve(thread_count_);
|
||||||
|
for (std::size_t i = 0; i < thread_count_; ++i)
|
||||||
|
workers_.emplace_back([this, i] { worker_loop(i); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() override {
|
||||||
|
stopped_.store(true, std::memory_order_seq_cst);
|
||||||
|
for (auto& q : queues_) {
|
||||||
|
std::lock_guard lock(q->mx);
|
||||||
|
std::size_t discarded = q->pq.size();
|
||||||
|
while (!q->pq.empty()) q->pq.pop();
|
||||||
|
total_.fetch_sub(discarded, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
cv_.notify_all();
|
||||||
|
for (auto& t : workers_) if (t.joinable()) t.join();
|
||||||
|
workers_.clear();
|
||||||
|
queues_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void drain() override {
|
||||||
|
std::unique_lock lock(drain_mx_);
|
||||||
|
drain_cv_.wait(lock, [this] {
|
||||||
|
return total_.load(std::memory_order_acquire) == 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void submit(std::function<void()> task, float priority = 0.5f) override {
|
||||||
|
std::size_t target = next_.fetch_add(1, std::memory_order_relaxed) % thread_count_;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(queues_[target]->mx);
|
||||||
|
queues_[target]->pq.push(
|
||||||
|
{std::move(task), priority, seq_.fetch_add(1, std::memory_order_relaxed)});
|
||||||
|
}
|
||||||
|
total_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
submitted_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
cv_.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t thread_count() const { return thread_count_; }
|
||||||
|
|
||||||
|
// ── IPoolProbe ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PoolSnapshot snapshot(const std::string& name) const override {
|
||||||
|
std::size_t a = active_.load(std::memory_order_relaxed);
|
||||||
|
std::size_t t = total_.load(std::memory_order_relaxed);
|
||||||
|
return {
|
||||||
|
name, thread_count_,
|
||||||
|
t > a ? t - a : 0, // queued (approximate)
|
||||||
|
a, // executing
|
||||||
|
submitted_.load(std::memory_order_relaxed),
|
||||||
|
completed_.load(std::memory_order_relaxed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Task {
|
||||||
|
std::function<void()> fn;
|
||||||
|
float priority;
|
||||||
|
uint64_t seq;
|
||||||
|
// max-heap: higher priority runs first; older task wins tie
|
||||||
|
bool operator<(const Task& o) const {
|
||||||
|
if (priority != o.priority) return priority < o.priority;
|
||||||
|
return seq > o.seq;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separate cache lines to prevent false sharing between adjacent queues.
|
||||||
|
struct alignas(64) WorkerQueue {
|
||||||
|
std::priority_queue<Task> pq;
|
||||||
|
std::mutex mx;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::optional<std::function<void()>> try_pop(WorkerQueue& q) {
|
||||||
|
std::lock_guard lock(q.mx);
|
||||||
|
if (q.pq.empty()) return std::nullopt;
|
||||||
|
auto fn = std::move(const_cast<Task&>(q.pq.top()).fn);
|
||||||
|
q.pq.pop();
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::function<void()>> try_steal(std::size_t thief) {
|
||||||
|
// Find the most-loaded peer without blocking — racy peek is fine.
|
||||||
|
std::size_t victim = thief, best = 0;
|
||||||
|
for (std::size_t i = 0; i < queues_.size(); ++i) {
|
||||||
|
if (i == thief) continue;
|
||||||
|
std::unique_lock lk(queues_[i]->mx, std::try_to_lock);
|
||||||
|
if (!lk) continue;
|
||||||
|
std::size_t n = queues_[i]->pq.size();
|
||||||
|
if (n > best) { best = n; victim = i; }
|
||||||
|
}
|
||||||
|
if (victim == thief) return std::nullopt;
|
||||||
|
return try_pop(*queues_[victim]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void execute(std::function<void()>& fn) {
|
||||||
|
active_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
fn();
|
||||||
|
completed_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
active_.fetch_sub(1, std::memory_order_relaxed);
|
||||||
|
// Notify drain() if this was the last in-flight task.
|
||||||
|
// acq_rel ensures the decrement is visible before any drain() load.
|
||||||
|
if (total_.fetch_sub(1, std::memory_order_acq_rel) == 1)
|
||||||
|
drain_cv_.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
void worker_loop(std::size_t id) {
|
||||||
|
while (true) {
|
||||||
|
if (auto fn = try_pop(*queues_[id])) { execute(*fn); continue; }
|
||||||
|
if (auto fn = try_steal(id)) { execute(*fn); continue; }
|
||||||
|
|
||||||
|
std::unique_lock lock(cv_mx_);
|
||||||
|
cv_.wait(lock, [this] {
|
||||||
|
return stopped_.load(std::memory_order_seq_cst)
|
||||||
|
|| total_.load(std::memory_order_relaxed) > 0;
|
||||||
|
});
|
||||||
|
if (stopped_.load(std::memory_order_seq_cst)
|
||||||
|
&& total_.load(std::memory_order_relaxed) == 0)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::size_t thread_count_;
|
||||||
|
std::vector<std::unique_ptr<WorkerQueue>> queues_;
|
||||||
|
std::vector<std::thread> workers_;
|
||||||
|
|
||||||
|
std::mutex cv_mx_;
|
||||||
|
std::condition_variable cv_;
|
||||||
|
std::mutex drain_mx_;
|
||||||
|
std::condition_variable drain_cv_;
|
||||||
|
|
||||||
|
std::atomic<bool> stopped_{true};
|
||||||
|
std::atomic<size_t> total_{0}; // queued + executing
|
||||||
|
std::atomic<size_t> active_{0}; // executing only (for snapshot)
|
||||||
|
std::atomic<size_t> next_{0}; // round-robin submit cursor
|
||||||
|
std::atomic<uint64_t> seq_{0}; // tie-break for equal-priority tasks
|
||||||
|
std::atomic<uint64_t> submitted_{0};
|
||||||
|
std::atomic<uint64_t> completed_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace kpn
|
||||||
@ -2,7 +2,7 @@
|
|||||||
#include "channel.hpp"
|
#include "channel.hpp"
|
||||||
#include "diagnostics.hpp"
|
#include "diagnostics.hpp"
|
||||||
#include "fanout.hpp"
|
#include "fanout.hpp"
|
||||||
#include "node.hpp"
|
#include "inode.hpp"
|
||||||
#include "port.hpp"
|
#include "port.hpp"
|
||||||
#include "tmp/fanout_groups.hpp"
|
#include "tmp/fanout_groups.hpp"
|
||||||
#include "tmp/topo_sort.hpp"
|
#include "tmp/topo_sort.hpp"
|
||||||
@ -15,6 +15,7 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
#include <tuple>
|
#include <tuple>
|
||||||
#include <type_traits>
|
#include <type_traits>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
@ -75,6 +76,10 @@ std::string node_display_name() {
|
|||||||
return std::string(lbl);
|
return std::string(lbl);
|
||||||
} else if constexpr (requires { NodeT::is_fanout_node; NodeT::unique_tag; }) {
|
} else if constexpr (requires { NodeT::is_fanout_node; NodeT::unique_tag; }) {
|
||||||
return "fanout[" + std::to_string(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; }) {
|
} else if constexpr (requires { NodeT::unique_tag; }) {
|
||||||
return "node[" + std::to_string(NodeT::unique_tag) + "]";
|
return "node[" + std::to_string(NodeT::unique_tag) + "]";
|
||||||
} else {
|
} else {
|
||||||
@ -115,7 +120,7 @@ public:
|
|||||||
web_debug_port_,
|
web_debug_port_,
|
||||||
[this]() {
|
[this]() {
|
||||||
auto s = collect_snapshots();
|
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();
|
web_server_->start();
|
||||||
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
|
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
|
||||||
@ -123,7 +128,9 @@ public:
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void stop() override {
|
void stop() override { halt(); }
|
||||||
|
|
||||||
|
void halt() override {
|
||||||
stop_flag_ = true;
|
stop_flag_ = true;
|
||||||
#ifdef KPN_WEB_DEBUG
|
#ifdef KPN_WEB_DEBUG
|
||||||
if (web_server_) web_server_->stop();
|
if (web_server_) web_server_->stop();
|
||||||
@ -134,6 +141,22 @@ public:
|
|||||||
(*it)->stop();
|
(*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_; }
|
bool running() const override { return !stop_flag_; }
|
||||||
|
|
||||||
void set_name(std::string name) override { name_ = std::move(name); }
|
void set_name(std::string name) override { name_ = std::move(name); }
|
||||||
@ -160,6 +183,12 @@ public:
|
|||||||
resource_probes_.emplace_back(name, probe);
|
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
|
// Print diagnostics using compile-time node labels
|
||||||
void print_diagnostics(std::ostream& os = std::cerr) const {
|
void print_diagnostics(std::ostream& os = std::cerr) const {
|
||||||
os << "\n┌─ KPN++ StaticNetwork diagnostics ─────────────────────────────\n";
|
os << "\n┌─ KPN++ StaticNetwork diagnostics ─────────────────────────────\n";
|
||||||
@ -179,6 +208,7 @@ private:
|
|||||||
std::vector<NodeSnapshot> nodes;
|
std::vector<NodeSnapshot> nodes;
|
||||||
std::vector<ChannelSnapshot> channels;
|
std::vector<ChannelSnapshot> channels;
|
||||||
std::vector<ResourceSnapshot> resources;
|
std::vector<ResourceSnapshot> resources;
|
||||||
|
std::vector<PoolSnapshot> pools;
|
||||||
double elapsed_s;
|
double elapsed_s;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -200,7 +230,23 @@ private:
|
|||||||
for (auto& [name, probe] : resource_probes_)
|
for (auto& [name, probe] : resource_probes_)
|
||||||
resources.push_back(probe->snapshot(name));
|
resources.push_back(probe->snapshot(name));
|
||||||
|
|
||||||
return {std::move(nodes), std::move(channels), std::move(resources), elapsed_s};
|
std::vector<PoolSnapshot> pools;
|
||||||
|
for (auto& [name, probe] : pool_probes_)
|
||||||
|
pools.push_back(probe->snapshot(name));
|
||||||
|
|
||||||
|
return {std::move(nodes), std::move(channels), std::move(resources), std::move(pools), elapsed_s};
|
||||||
|
}
|
||||||
|
|
||||||
|
void drain_all_channels() const {
|
||||||
|
bool any_full = true;
|
||||||
|
while (any_full) {
|
||||||
|
any_full = false;
|
||||||
|
for (auto& probe : channel_probes_) {
|
||||||
|
if (probe->snapshot().current_fill > 0) { any_full = true; break; }
|
||||||
|
}
|
||||||
|
if (any_full)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string name_;
|
std::string name_;
|
||||||
@ -212,6 +258,7 @@ private:
|
|||||||
std::vector<std::string> fanout_node_names_;
|
std::vector<std::string> fanout_node_names_;
|
||||||
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
|
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
|
||||||
std::vector<std::pair<std::string, IResourceProbe*>> resource_probes_;
|
std::vector<std::pair<std::string, IResourceProbe*>> resource_probes_;
|
||||||
|
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
|
||||||
clock_t::time_point start_time_;
|
clock_t::time_point start_time_;
|
||||||
#ifdef KPN_WEB_DEBUG
|
#ifdef KPN_WEB_DEBUG
|
||||||
uint16_t web_debug_port_{9090};
|
uint16_t web_debug_port_{9090};
|
||||||
|
|||||||
@ -83,4 +83,18 @@ template<typename F>
|
|||||||
inline constexpr std::size_t output_count_v =
|
inline constexpr std::size_t output_count_v =
|
||||||
std::tuple_size_v<normalised_return_t<return_t<F>>>;
|
std::tuple_size_v<normalised_return_t<return_t<F>>>;
|
||||||
|
|
||||||
|
// ── repeat_tuple: std::tuple<T, T, ..., T> with N repetitions ────────────────
|
||||||
|
|
||||||
|
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
|
||||||
|
struct repeat_tuple;
|
||||||
|
|
||||||
|
template<typename T, std::size_t N, std::size_t... Is>
|
||||||
|
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
|
||||||
|
template<std::size_t> using always_T = T;
|
||||||
|
using type = std::tuple<always_T<Is>...>;
|
||||||
|
};
|
||||||
|
|
||||||
|
template<typename T, std::size_t N>
|
||||||
|
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
|
||||||
|
|
||||||
} // namespace kpn
|
} // namespace kpn
|
||||||
|
|||||||
@ -239,11 +239,13 @@ private:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── PythonConverter ───────────────────────────────────────────────────────────
|
// ── PythonConverter ───────────────────────────────────────────────────────────
|
||||||
|
// Specialise for each type you want to use across a PyNetwork.
|
||||||
|
// Required members: to_python(const T&), from_python(nb::object).
|
||||||
|
// Optional member: static constexpr const char* type_name = "friendly_name";
|
||||||
|
// Built-in specialisations for int/float/double/bool/std::string are provided
|
||||||
|
// automatically when you include <kpn/python/auto_bind.hpp>.
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
struct PythonConverter {
|
struct PythonConverter {};
|
||||||
static_assert(sizeof(T) == 0,
|
|
||||||
"PythonConverter<T> must be specialised for each type used in a PyNetwork");
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace kpn
|
} // namespace kpn
|
||||||
|
|||||||
@ -47,7 +47,8 @@ static std::pair<std::string,std::string> parse_edge_name(const std::string& nam
|
|||||||
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
|
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
|
||||||
const std::vector<ChannelSnapshot>& channels,
|
const std::vector<ChannelSnapshot>& channels,
|
||||||
const std::vector<ResourceSnapshot>& resources = {},
|
const std::vector<ResourceSnapshot>& resources = {},
|
||||||
double elapsed_s = 0.0) {
|
double elapsed_s = 0.0,
|
||||||
|
const std::vector<PoolSnapshot>& pools = {}) {
|
||||||
std::ostringstream o;
|
std::ostringstream o;
|
||||||
o << std::fixed;
|
o << std::fixed;
|
||||||
o.precision(2);
|
o.precision(2);
|
||||||
@ -97,6 +98,20 @@ static std::string to_json(const std::vector<NodeSnapshot>& nodes,
|
|||||||
<< ",\"held\":" << (r.held ? "true" : "false")
|
<< ",\"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 << "]}";
|
o << "]}";
|
||||||
return o.str();
|
return o.str();
|
||||||
}
|
}
|
||||||
@ -164,7 +179,7 @@ const defs = svg.append('defs');
|
|||||||
const g = svg.append('g');
|
const g = svg.append('g');
|
||||||
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
|
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 = [];
|
let nodes = [], links = [];
|
||||||
|
|
||||||
function edgeColor(fill_pct) {
|
function edgeColor(fill_pct) {
|
||||||
@ -211,6 +226,10 @@ function init(data) {
|
|||||||
.attr('class', 'link-label')
|
.attr('class', 'link-label')
|
||||||
.text(d => `${d.fill_pct.toFixed(0)}%`);
|
.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
|
// Nodes
|
||||||
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
|
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
|
||||||
.attr('class', 'node')
|
.attr('class', 'node')
|
||||||
@ -296,6 +315,7 @@ function update(data) {
|
|||||||
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
|
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
|
||||||
.attr('marker-end', d => edgeArrow(d.fill_pct));
|
.attr('marker-end', d => edgeArrow(d.fill_pct));
|
||||||
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
|
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
|
||||||
|
edgeLabelBwSel.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ticked() {
|
function ticked() {
|
||||||
@ -322,6 +342,10 @@ function ticked() {
|
|||||||
.attr('x', d => (d.source.x + d.target.x)/2)
|
.attr('x', d => (d.source.x + d.target.x)/2)
|
||||||
.attr('y', d => (d.source.y + d.target.y)/2 - 6);
|
.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})`);
|
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,164 +1,39 @@
|
|||||||
#define KPN_BUILD_PYTHON
|
#define KPN_BUILD_PYTHON
|
||||||
#include <kpn/python/bindings.hpp>
|
#include <kpn/python/auto_bind.hpp>
|
||||||
#include <nanobind/nanobind.h>
|
|
||||||
#include <nanobind/stl/shared_ptr.h>
|
|
||||||
#include <nanobind/stl/string.h>
|
|
||||||
#include <nanobind/stl/vector.h>
|
|
||||||
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <map>
|
|
||||||
|
|
||||||
namespace nb = nanobind;
|
namespace nb = nanobind;
|
||||||
using namespace kpn;
|
using namespace kpn;
|
||||||
using namespace kpn::python;
|
using namespace kpn::python;
|
||||||
|
|
||||||
// ── Node functions for the hello-pipeline examples ────────────────────────────
|
// ── Demo node functions (hello-pipeline) ──────────────────────────────────────
|
||||||
|
|
||||||
static int produce() { return 42; }
|
static int produce() { return 42; }
|
||||||
static int double_it(int x) { return x * 2; }
|
static int double_it(int x) { return x * 2; }
|
||||||
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
|
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
|
||||||
|
|
||||||
// ── Variant type ──────────────────────────────────────────────────────────────
|
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||||
// Deduplicated port types across all registered node functions.
|
// Variant type is auto-deduced as std::variant<int> from the port types above.
|
||||||
// produce: () → int | double_it: int → int | print_it: int → void
|
|
||||||
// Unique types: int
|
|
||||||
|
|
||||||
using KpnVariant = std::variant<int>;
|
using DemoNodes = NodeRegistry<
|
||||||
using Net = PyNetwork<KpnVariant>;
|
Entry<produce, "produce">,
|
||||||
using ProduceNode = VariantNodeWrapper<produce, KpnVariant>;
|
Entry<double_it, "double_it">,
|
||||||
using DoubleItNode= VariantNodeWrapper<double_it, KpnVariant>;
|
Entry<print_it, "print_it">
|
||||||
using PrintItNode = VariantNodeWrapper<print_it, KpnVariant>;
|
>;
|
||||||
|
|
||||||
// ── Converter helpers for int ─────────────────────────────────────────────────
|
// ── Module ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static nb::object int_to_py(const KpnVariant& v) {
|
|
||||||
return nb::int_(std::get<int>(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
static KpnVariant int_from_py(nb::object o) {
|
|
||||||
return KpnVariant{ nb::cast<int>(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<int>(
|
|
||||||
[](const int& v) -> nb::object { return nb::int_(v); },
|
|
||||||
[](nb::object o) -> int { return nb::cast<int>(o); }
|
|
||||||
);
|
|
||||||
net.register_tap_factory<int>();
|
|
||||||
}
|
|
||||||
|
|
||||||
NB_MODULE(kpn_python, m) {
|
NB_MODULE(kpn_python, m) {
|
||||||
m.doc() = "KPN++ Python bindings — Kahn Process Network library";
|
m.doc() = "KPN++ Python bindings — Kahn Process Network library";
|
||||||
|
|
||||||
// ── IVariantNode base ─────────────────────────────────────────────────────
|
// Registers: Network, INode, ProduceNode, DoubleItNode, PrintItNode,
|
||||||
nb::class_<IVariantNode<KpnVariant>>(m, "INode");
|
// make_produce(), make_double_it(), make_print_it()
|
||||||
|
bind_network<DemoNodes>(m);
|
||||||
|
|
||||||
// ── Concrete C++ node wrappers ─────────────────────────────────────────────
|
// Also expose raw functions for direct testing (no network needed):
|
||||||
// Factories return shared_ptr so Python can pass them to net.add().
|
// kpn.produce() → 42
|
||||||
nb::class_<ProduceNode, IVariantNode<KpnVariant>>(m, "ProduceNode")
|
// kpn.double_it(5) → 10
|
||||||
.def("__init__", [](ProduceNode* self, std::size_t cap) {
|
// kpn.print_it(84) → prints "result: 84", returns None
|
||||||
new (self) ProduceNode(cap);
|
bind_debug<DemoNodes>(m);
|
||||||
}, nb::arg("capacity") = 5);
|
|
||||||
|
|
||||||
nb::class_<DoubleItNode, IVariantNode<KpnVariant>>(m, "DoubleItNode")
|
|
||||||
.def("__init__", [](DoubleItNode* self, std::size_t cap) {
|
|
||||||
new (self) DoubleItNode(cap);
|
|
||||||
}, nb::arg("capacity") = 5);
|
|
||||||
|
|
||||||
nb::class_<PrintItNode, IVariantNode<KpnVariant>>(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<IVariantNode<KpnVariant>> {
|
|
||||||
return std::make_shared<ProduceNode>(cap);
|
|
||||||
}, nb::arg("capacity") = 5);
|
|
||||||
m.def("make_double_it", [](std::size_t cap) -> std::shared_ptr<IVariantNode<KpnVariant>> {
|
|
||||||
return std::make_shared<DoubleItNode>(cap);
|
|
||||||
}, nb::arg("capacity") = 5);
|
|
||||||
m.def("make_print_it", [](std::size_t cap) -> std::shared_ptr<IVariantNode<KpnVariant>> {
|
|
||||||
return std::make_shared<PrintItNode>(cap);
|
|
||||||
}, nb::arg("capacity") = 5);
|
|
||||||
|
|
||||||
// ── Network ───────────────────────────────────────────────────────────────
|
|
||||||
nb::class_<Net>(m, "Network")
|
|
||||||
.def(nb::init<>())
|
|
||||||
|
|
||||||
// add(name, c++_node) — for pre-constructed C++ nodes
|
|
||||||
.def("add", [](Net& self, std::string name,
|
|
||||||
std::shared_ptr<IVariantNode<KpnVariant>> 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<std::string> in_names,
|
|
||||||
std::vector<std::string> out_names,
|
|
||||||
std::size_t capacity)
|
|
||||||
{
|
|
||||||
std::vector<std::type_index> 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<std::type_index, std::function<nb::object(const KpnVariant&)>> to_py;
|
|
||||||
std::map<std::type_index, std::function<KpnVariant(nb::object)>> from_py;
|
|
||||||
std::map<std::type_index, PyNode<KpnVariant>::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<IVariantChannel<KpnVariant>> {
|
|
||||||
auto ch = std::make_shared<Channel<int>>(cap);
|
|
||||||
return std::make_shared<VariantChannel<int, KpnVariant>>(std::move(ch));
|
|
||||||
};
|
|
||||||
|
|
||||||
auto node = std::make_shared<PyNode<KpnVariant>>(
|
|
||||||
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<std::string>{},
|
|
||||||
nb::arg("outputs") = std::vector<std::string>{},
|
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
|
|||||||
121
scripts/render_readme.py
Normal file
121
scripts/render_readme.py
Normal file
@ -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 path/to/file.cpp snippet_name -->
|
||||||
|
|
||||||
|
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'<!--\s*@snippet\s+(\S+)\s+(\S+)\s*-->')
|
||||||
|
|
||||||
|
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()
|
||||||
@ -33,6 +33,8 @@ add_executable(kpn_tests
|
|||||||
test_network.cpp
|
test_network.cpp
|
||||||
test_static_network.cpp
|
test_static_network.cpp
|
||||||
test_shared_resource.cpp
|
test_shared_resource.cpp
|
||||||
|
test_pool_node.cpp
|
||||||
|
test_scheduler.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(kpn_tests PRIVATE
|
target_link_libraries(kpn_tests PRIVATE
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <catch2/catch_approx.hpp>
|
||||||
#include <kpn/channel.hpp>
|
#include <kpn/channel.hpp>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
using namespace kpn;
|
using namespace kpn;
|
||||||
|
|
||||||
@ -70,13 +72,21 @@ TEST_CASE("push to disabled channel is silently dropped", "[channel]") {
|
|||||||
REQUIRE(ch.size() == 0);
|
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<int> ch(5);
|
Channel<int> ch(5);
|
||||||
ch.push(1);
|
ch.push(1);
|
||||||
ch.push(2);
|
ch.push(2);
|
||||||
REQUIRE(ch.size() == 2);
|
REQUIRE(ch.size() == 2);
|
||||||
ch.disable();
|
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]") {
|
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();
|
auto out = ch.pop();
|
||||||
REQUIRE(out.tag == 123);
|
REQUIRE(out.tag == 123);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ChannelDataSize / bytes_pushed tests ─────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("bytes_pushed uses sizeof(T) by default for POD type", "[channel][bandwidth]") {
|
||||||
|
Channel<int> 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<Blob> 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<uint8_t> pixels;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Specialise ChannelDataSize so the channel counts actual pixel bytes.
|
||||||
|
template<>
|
||||||
|
struct kpn::ChannelDataSize<FakeFrame> {
|
||||||
|
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<FakeFrame> ch(10);
|
||||||
|
ch.push(FakeFrame{std::vector<uint8_t>(1000)});
|
||||||
|
ch.push(FakeFrame{std::vector<uint8_t>(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<FakeFrame> ch(10);
|
||||||
|
// Push 10 frames of 1 MB each
|
||||||
|
for (int i = 0; i < 10; ++i)
|
||||||
|
ch.push(FakeFrame{std::vector<uint8_t>(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<int> 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);
|
||||||
|
}
|
||||||
|
|||||||
241
tests/test_pool_node.cpp
Normal file
241
tests/test_pool_node.cpp
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <kpn/scheduler.hpp>
|
||||||
|
#include <kpn/pool_node.hpp>
|
||||||
|
#include <kpn/interrupt_node.hpp>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
using namespace kpn;
|
||||||
|
|
||||||
|
static int double_it(int x) { return x * 2; }
|
||||||
|
static std::tuple<int, float> 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<int> 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<bool> 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<int> order;
|
||||||
|
std::mutex order_mutex;
|
||||||
|
|
||||||
|
std::atomic<bool> 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<int>{2, 1});
|
||||||
|
pool.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PoolNode ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("pool node input/output counts", "[pool_node]") {
|
||||||
|
STATIC_REQUIRE(PoolNode<double_it>::input_count == 1);
|
||||||
|
STATIC_REQUIRE(PoolNode<double_it>::output_count == 1);
|
||||||
|
STATIC_REQUIRE(PoolNode<split_it>::output_count == 2);
|
||||||
|
STATIC_REQUIRE(PoolNode<consume_it>::output_count == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("pool node processes items end-to-end", "[pool_node]") {
|
||||||
|
auto pool = std::make_shared<ThreadPool>(2);
|
||||||
|
pool->start();
|
||||||
|
|
||||||
|
auto node = make_pool_node<double_it>(pool);
|
||||||
|
Channel<int> 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<ThreadPool>(2);
|
||||||
|
pool->start();
|
||||||
|
|
||||||
|
auto node = make_pool_node<double_it>(pool, 20); // capacity 20
|
||||||
|
Channel<int> 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<int> 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<ThreadPool>(2);
|
||||||
|
pool->start();
|
||||||
|
|
||||||
|
auto node = make_pool_node<double_it>(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<ThreadPool>(4);
|
||||||
|
pool->start();
|
||||||
|
|
||||||
|
auto src = make_pool_node<double_it>(pool);
|
||||||
|
auto transform = make_pool_node<double_it>(pool);
|
||||||
|
Channel<int> 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<int> 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<std::size_t>(N));
|
||||||
|
for (int i = 0; i < N; ++i)
|
||||||
|
REQUIRE(results[i] == (i + 1) * 4); // double_it twice
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── InterruptNode ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
static std::atomic<int> 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<ThreadPool>(2);
|
||||||
|
pool->start();
|
||||||
|
|
||||||
|
g_interrupt_counter.store(0);
|
||||||
|
auto node = make_interrupt_node<interrupt_produce>(pool, out<>{});
|
||||||
|
Channel<int> 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<int> results;
|
||||||
|
for (int i = 0; i < N; ++i)
|
||||||
|
results.push_back(out_ch.pop());
|
||||||
|
|
||||||
|
node.stop();
|
||||||
|
pool->stop();
|
||||||
|
|
||||||
|
REQUIRE(results.size() == static_cast<std::size_t>(N));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("interrupt node does not fire without trigger", "[interrupt_node]") {
|
||||||
|
auto pool = std::make_shared<ThreadPool>(2);
|
||||||
|
pool->start();
|
||||||
|
|
||||||
|
g_interrupt_counter.store(0);
|
||||||
|
auto node = make_interrupt_node<interrupt_produce>(pool, out<>{});
|
||||||
|
Channel<int> 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<ThreadPool>(2);
|
||||||
|
pool->start();
|
||||||
|
|
||||||
|
g_interrupt_counter.store(0);
|
||||||
|
auto node = make_interrupt_node<interrupt_produce>(pool, out<>{});
|
||||||
|
Channel<int> 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();
|
||||||
|
}
|
||||||
229
tests/test_scheduler.cpp
Normal file
229
tests/test_scheduler.cpp
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <kpn/scheduler.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
using namespace kpn;
|
||||||
|
using namespace std::chrono_literals;
|
||||||
|
|
||||||
|
// ── basic execution ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("scheduler runs submitted tasks", "[scheduler]") {
|
||||||
|
ThreadPool pool(2);
|
||||||
|
pool.start();
|
||||||
|
|
||||||
|
std::atomic<int> 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<int> 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<int> 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<int> 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<float> 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<int> 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<int>{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<bool> 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<uint64_t>(N));
|
||||||
|
REQUIRE(snap.tasks_completed == static_cast<uint64_t>(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<int> 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<int> 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();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user