Author SHA1 Message Date
dtourolleandClaude Opus 4.8 7c6a8be2b7 ci: provoke pipeline (touch Dockerfile + code)
🚦 CI / changes (push) Successful in 32s
🚦 CI / docker (push) Successful in 1m0s
🚦 CI / test (push) Successful in 4m47s
🚦 CI / docs (push) Successful in 13s
Trivial comment changes to exercise the new CI orchestration: the
docker job should rebuild+push the builder image first, then test
and docs run against the fresh image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:46:35 +02:00
dtourolle 4c0f1f6923 fix CI
🚦 CI / changes (push) Successful in 33s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Has been skipped
🚦 CI / docs (push) Has been skipped
2026-06-20 09:38:26 +02:00
dtourolle 6b52526e44 Auto-build docker when docker file changes.
🚦 CI / changes (push) Failing after 5s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Has been skipped
🚦 CI / docs (push) Has been skipped
2026-06-20 09:16:27 +02:00
dtourolle 7cb92a4091 Build docs with a pre-configured docker
🧪 Test / test (push) Successful in 4m41s
2026-06-20 08:55:48 +02:00
dtourolle 20668d6955 Fix docker image
🧪 Test / test (push) Successful in 7m31s
2026-06-19 22:29:46 +02:00
dtourolle 6f384dc4b5 Added callbacks for node errors and fifo overflow
📚 Docs / deploy (push) Failing after 7s
🧪 Test / test (push) Has been cancelled
Add new doc system which should/might deploy to pages.
2026-06-19 22:26:39 +02:00
dtourolleandClaude Opus 4.8 79916f1da1 Set node names in make_network for user and fanout nodes
🧪 Test / test (push) Successful in 6m22s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:20:50 +02:00
dtourolle f6bcaa15b0 Performance improvements, better readme and complete python bindings
🧪 Test / test (push) Failing after 28m30s
2026-05-12 21:23:33 +02:00
dtourolle c39db82763 Add a per node error handler possibility
🧪 Test / test (push) Successful in 6m7s
2026-05-10 19:51:23 +02:00
dtourolle 1e9ba5ee66 Add a unified observability interface for applications with multiple networks
🧪 Test / test (push) Successful in 6m9s
2026-05-10 19:08:40 +02:00
dtourolle 278c122e8f Add shared reasource tag to allow coordination of usage
🧪 Test / test (push) Successful in 6m8s
2026-05-09 15:22:27 +02:00
64 changed files with 7296 additions and 865 deletions
+79
View File
@@ -0,0 +1,79 @@
name: '🚦 CI'
# Single orchestrator. This is the only workflow that triggers on push/PR.
# It decides which reusable sub-workflows to run and in what order:
# changes ─┬─> docker (only if the Dockerfile/requirements changed) ─┬─> test
# │ └─> docs
# When the builder image is rebuilt it MUST finish (and push) before test/docs
# run, so they validate against the fresh image.
on:
push:
branches:
- master
- develop
pull_request:
branches:
- master
- develop
workflow_dispatch:
jobs:
# Detect which parts of the repo changed in this push/PR.
changes:
runs-on: linux/amd64
# Runs in the builder image because the host has no Node, which the
# JS-based checkout/paths-filter actions require.
container:
image: gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
outputs:
dockerfile: ${{ steps.filter.outputs.dockerfile }}
code: ${{ steps.filter.outputs.code }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Detect changed paths
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
dockerfile:
- 'Dockerfile.builder'
- 'docs/requirements.txt'
docs:
- 'docs/**'
- 'mkdocs.yml'
- 'examples/**/*.cpp'
code:
- 'src/**'
- 'include/**'
- 'tests/**'
- 'examples/**'
- 'python/**'
- 'CMakeLists.txt'
- '**/*.cpp'
- '**/*.hpp'
- '**/*.h'
# Rebuild the builder image first, but only when it actually changed.
# On pull requests we build to validate the Dockerfile but do not push.
docker:
needs: changes
if: ${{ needs.changes.outputs.dockerfile == 'true' }}
uses: ./.gitea/workflows/docker.yaml
with:
push: ${{ github.event_name != 'pull_request' }}
# Runs after docker (if docker ran). A skipped docker job is fine; a failed
# one blocks this via !failure(). Re-run tests when code OR the image changed.
test:
needs: [changes, docker]
if: ${{ !failure() && !cancelled() && (needs.changes.outputs.code == 'true' || needs.changes.outputs.dockerfile == 'true') }}
uses: ./.gitea/workflows/test.yaml
docs:
needs: [changes, docker]
if: ${{ !failure() && !cancelled() && github.ref == 'refs/heads/master' && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.dockerfile == 'true') }}
uses: ./.gitea/workflows/docs.yaml
secrets: inherit
+54
View File
@@ -0,0 +1,54 @@
name: '🐳 Builder Image'
# Reusable workflow: builds (and optionally pushes) the kpnpp-builder image.
# It is called by ci.yaml only when Dockerfile.builder or docs/requirements.txt
# change. It runs on the host runner (NOT inside the builder container) because
# it needs the Docker CLI/daemon.
on:
workflow_call:
inputs:
push:
description: 'Push the built image to the registry'
type: boolean
default: true
workflow_dispatch:
inputs:
push:
description: 'Push the built image to the registry'
type: boolean
default: true
jobs:
build:
runs-on: linux/amd64
steps:
# This job runs on the host (not in a container) so it can reach the
# host Docker daemon and reuse the cached registry credentials. The host
# has no Node, so the JS-based actions/checkout can't run here; do a
# minimal shallow fetch of this commit with plain git instead.
- name: Checkout repository
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: basic $(printf '%s' '${{ github.actor }}:${{ github.token }}' | base64 -w0)" \
fetch --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
# No docker login step: the host runner was authenticated to
# gitea.tourolle.paris with `docker login` during setup, so its cached
# credentials in ~/.docker/config.json cover the push below.
- name: Build builder image
# Context is the repo root because Dockerfile.builder COPYs
# docs/requirements.txt during the build.
run: |
docker build \
-f Dockerfile.builder \
-t gitea.tourolle.paris/dtourolle/kpnpp-builder:latest \
-t gitea.tourolle.paris/dtourolle/kpnpp-builder:${{ github.sha }} \
.
- name: Push builder image
if: ${{ inputs.push }}
run: |
docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:${{ github.sha }}
+34
View File
@@ -0,0 +1,34 @@
name: '📚 Docs'
# Triggering and path filtering are owned by ci.yaml (the orchestrator), which
# calls this as a reusable workflow. workflow_dispatch is kept for manual runs.
on:
workflow_call:
workflow_dispatch:
jobs:
deploy:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history needed for mkdocs gh-deploy
- name: Configure git identity
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
- name: Build and deploy to gitea-pages branch
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdocs gh-deploy \
--force \
--remote-branch gitea-pages \
--remote-name origin \
--message "docs: deploy from ${{ github.sha }}"
+15 -16
View File
@@ -1,18 +1,9 @@
name: '🧪 Test'
# Triggering and path filtering are owned by ci.yaml (the orchestrator), which
# calls this as a reusable workflow. workflow_dispatch is kept for manual runs.
on:
push:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
workflow_call:
workflow_dispatch:
jobs:
@@ -41,7 +32,7 @@ jobs:
-G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DKPN_BUILD_TESTS=ON \
-DKPN_BUILD_EXAMPLES=OFF \
-DKPN_BUILD_EXAMPLES=ON \
-DKPN_BUILD_PYTHON=ON \
-DFETCHCONTENT_BASE_DIR=$HOME/.cmake/fetchcontent
@@ -49,18 +40,26 @@ jobs:
working-directory: test-${{ github.run_id }}
run: cmake --build build --parallel
- name: Run tests
- name: Run unit tests
working-directory: test-${{ github.run_id }}
run: |
cd build
ctest --output-on-failure --output-junit test-results.xml
ctest --output-on-failure --output-junit test-results.xml --label-exclude examples
- name: Run example smoke tests
working-directory: test-${{ github.run_id }}
run: |
cd build
ctest --output-on-failure --output-junit example-results.xml -L examples
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: test-${{ github.run_id }}/build/test-results.xml
path: |
test-${{ github.run_id }}/build/test-results.xml
test-${{ github.run_id }}/build/example-results.xml
retention-days: 7
- name: Cleanup
+2 -1
View File
@@ -1,6 +1,7 @@
# Build output
build/
build_debug/
site/
# Python
__pycache__/
*.py[cod]
+17
View File
@@ -48,6 +48,12 @@ if(KPN_BUILD_TESTS)
add_subdirectory(tests)
endif()
# ── Benchmarks ────────────────────────────────────────────────────────────────
option(KPN_BUILD_BENCHMARKS "Build benchmarks" OFF)
if(KPN_BUILD_BENCHMARKS)
add_subdirectory(benchmarks)
endif()
# ── Python bindings ───────────────────────────────────────────────────────────
if(KPN_BUILD_PYTHON)
find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED)
@@ -69,3 +75,14 @@ endif()
if(KPN_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
# ── Docs (README generation) ──────────────────────────────────────────────────
find_package(Python3 QUIET COMPONENTS Interpreter)
if(Python3_FOUND)
add_custom_target(docs
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/render_readme.py
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMENT "Rendering README.md from README.md.in"
VERBATIM
)
endif()
+9 -1
View File
@@ -1,4 +1,4 @@
# KPN++ Builder Image
# KPN++ Builder Image (CI: pipeline trigger)
# Pre-built image with GCC, CMake, Ninja, and Python dev headers for building and testing KPN++
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/kpnpp-builder:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
@@ -16,4 +16,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs \
&& rm -rf /var/lib/apt/lists/*
# Pre-install MkDocs dependencies so the docs workflow does not need to pip
# install at runtime. --break-system-packages is required because the Debian
# base marks the environment as externally managed (PEP 668); this is safe in
# a dedicated container image.
COPY docs/requirements.txt /tmp/docs-requirements.txt
RUN pip install --no-cache-dir --break-system-packages -r /tmp/docs-requirements.txt \
&& rm /tmp/docs-requirements.txt
WORKDIR /src
+357 -34
View File
@@ -50,31 +50,55 @@ A node wraps any callable. Its input types are taken from the function's paramet
```cpp
#include <kpn/kpn.hpp>
using namespace kpn;
```
// Single input, single output
int double_it(int x) { return x * 2; }
Source, transform, and sink — from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp):
// Multi-output — must return std::tuple
std::tuple<cv::Mat, cv::Mat> split(cv::Mat frame) { ... }
```cpp
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
```
// Sink — void return, no output ports
void display(cv::Mat frame) { cv::imshow("out", frame); }
Multi-output node returning a tuple — from [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp):
```cpp
// Multi-output: returns (key, value) as a tuple — KPN++ routes each element
// to its own output port automatically.
static std::tuple<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
**Index-only ports** (from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp)):
```cpp
// No port names (index-only access)
auto node = make_node<double_it>(/*fifo_capacity=*/5);
auto src = make_node<produce>(5);
auto dbl = make_node<double_it>(5);
auto sink = make_node<print_it>(5);
```
// Named input ports only
auto node = make_node<double_it>(in<"value">{}, 5);
**Named ports** (from [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp)):
// Named input and output ports
auto node = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
```cpp
// 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)
auto node = make_node<capture>(out<"colour","grey">{}, 5);
// count_words: named input "words", named outputs "count" and "words"
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.
@@ -83,22 +107,25 @@ Port names are NTTP `fixed_string` values — resolved entirely at compile time,
`Network` is **non-owning** — declare nodes first, then register them. Nodes must outlive the network.
```cpp
auto src = make_node<produce>(in<"x">{}, out<"value">{}, 5);
auto proc = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
From [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp):
```cpp
Network net;
net.add("src", src)
.add("proc", proc)
.connect("src", src.output<0>(), "proc", proc.input<0>()) // by index
.connect("src", src.template output<"value">(), "proc", proc.template input<"value">()) // by name
.build(); // runs cycle detection — throws NetworkCycleError on cycles
net.add("tok", tok)
.add("cnt", cnt)
.add("snk", snk)
.connect("tok", tok.template output<"words">(), "cnt", cnt.template input<"words">())
.connect("cnt", cnt.template output<"count">(), "snk", snk.template input<"count">())
.connect("cnt", cnt.template output<"words">(), "snk", snk.template input<"words">())
.build();
net.start();
// ... do work ...
std::this_thread::sleep_for(std::chrono::milliseconds(500));
net.stop();
```
`.build()` runs cycle detection — throws `NetworkCycleError` on cycles.
> **Named port syntax in template context:** when the node variable is `auto`-deduced, use `.template output<"name">()` and `.template input<"name">()` to help the parser.
### Channel Semantics
@@ -113,14 +140,36 @@ net.stop();
Large types (`sizeof > 8` or non-trivially-copyable) are stored as `std::shared_ptr<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
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;
};
```
### 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
`node.stop()` / `net.stop()`:
@@ -171,20 +220,155 @@ top.start();
## Display / GUI Nodes
**Do not wrap `imshow`/`waitKey` as a KPN node.** Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, wire the final output channel to a `Channel<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
Channel<cv::Mat> result_ch(8);
comp.set_output_channel<0>(&result_ch);
// ... build and start network ...
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
kpn::in<"composite", "edges">,
cv::Mat, cv::Mat> {
public:
DisplayNode() : MainThreadNode(8) {
cv::namedWindow("Cell Shade", cv::WINDOW_NORMAL);
cv::namedWindow("Edge Mask", cv::WINDOW_NORMAL);
cv::resizeWindow("Cell Shade", 1280, 720);
cv::resizeWindow("Edge Mask", 640, 360);
}
~DisplayNode() { cv::destroyAllWindows(); }
bool operator()(cv::Mat composite, cv::Mat edges) {
cv::imshow("Cell Shade", composite);
cv::Mat edges_bgr;
cv::cvtColor(edges, edges_bgr, cv::COLOR_GRAY2BGR);
cv::imshow("Edge Mask", edges_bgr);
int key = cv::waitKey(1);
if (key == 'q' || key == 27) return false;
return window_open("Cell Shade") && window_open("Edge Mask");
}
private:
static bool window_open(const char* name) {
try { return cv::getWindowProperty(name, cv::WND_PROP_VISIBLE) >= 1; }
catch (const cv::Exception&) { return false; }
}
};
```
Wire it into the network and drive it from the main thread:
```cpp
net.start();
// Main thread drives display — imshow/waitKey stay on the GUI thread.
// step() returns false when operator() returns false (q pressed / window closed).
while (disp.step())
cv::waitKey(8); // yield event loop when no frame ready
net.stop();
```
---
## OpenCV Cell-Shading Example
Real-time cell-shading pipeline from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp).
**Source node** — returns two frames (colour + grey) as a tuple, routing them to separate downstream branches:
```cpp
static std::tuple<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";
}
}
// Main thread display loop
while (true) {
cv::Mat frame;
if (!result_ch.try_pop(frame, std::chrono::milliseconds(100))) continue;
cv::imshow("output", frame);
if (cv::waitKey(1) == 'q') break;
if (cap.isOpened()) {
auto t0 = std::chrono::steady_clock::now();
cap >> frame;
auto elapsed = std::chrono::steady_clock::now() - t0;
if (elapsed < std::chrono::milliseconds(20))
std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed);
if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3);
} else {
static int tick = 0;
static cv::Mat grad = make_gradient(W, H);
++tick;
frame = grad.clone();
int r = 150 + (tick % 80) * 4;
cv::circle(frame, {W/2, H/2}, r, {255, 200, 0}, -1);
cv::circle(frame, {W/2, H/2}, r / 2, { 0, 128, 255}, -1);
cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200, 0, 200}, -1);
std::this_thread::sleep_for(std::chrono::milliseconds(33));
}
return {frame.clone(), frame.clone()};
}
```
**Full network wiring:**
```cpp
auto src = make_node<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();
```
@@ -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 **~27 µ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
```
@@ -254,4 +575,6 @@ python/
kpn_python.cpp — nanobind module entry point
examples/
01_hello_pipeline/ … 09_opencv_cellshade/
scripts/
render_readme.py — regenerates README.md from README.md.in
```
+402
View 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 **~27 µ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
View 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
View 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
}
}
+55
View File
@@ -0,0 +1,55 @@
# Channels
A `Channel<T>` is a lock-free SPSC (single-producer, single-consumer) ring buffer with atomic wait/notify.
## Semantics
- **Bounded**: fixed capacity set at construction. Default is 5 items.
- **Backpressure**: when full, `push()` throws `ChannelOverflowError` immediately — no blocking, no spin.
- **Blocking consumer**: `pop()` blocks until an item is available or the channel is disabled.
- **Disable**: `channel.disable()` stops accepting pushes and unblocks any waiting `pop()` with `ChannelClosedError`.
## Storage policy
Small trivially-copyable types (≤ 8 bytes) are stored by value. Larger types are heap-allocated and passed via `shared_ptr<const T>` — one allocation per push, zero-copy fan-out:
```cpp
--8<-- "examples/04_storage_policy/main.cpp:storage_policy_spec"
```
Specialize `kpn::ChannelDataSize<T>` for accurate bandwidth reporting on heap-owning types:
```cpp
template<>
struct kpn::ChannelDataSize<cv::Mat> {
static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
};
```
## Named ports
`in<"name">` and `out<"name">` tag nodes for readable wiring:
```cpp
--8<-- "examples/02_named_ports/main.cpp:named_port_creation"
```
Named ports are checked at compile time — a typo in a port name is a compile error.
## Capacity tuning
Set capacity per node at construction:
```cpp
auto node = make_node<my_func>(/*capacity=*/20);
```
Capacity is rounded up internally to the next power of two. Monitor fill levels via diagnostics to tune for your workload — a too-small capacity causes overflows; a too-large one wastes memory and hides producer/consumer speed mismatches.
## Spin count
`Channel` spins for up to ~4 µs (200 `pause` hints at ~20 ns each on x86) before sleeping on a futex. Set to 0 for power-constrained or predominantly-idle pipelines:
```cpp
Channel<int> ch(/*capacity=*/5, /*spin_count=*/0);
```
+84
View File
@@ -0,0 +1,84 @@
# Error Handling & Events
KPN++ provides three complementary layers for observing and reacting to failures.
---
## 1. Per-node error handler
Called when a node's function throws an unhandled exception. Return `true` to skip the failed invocation and keep running; `false` to stop the node.
```cpp
--8<-- "examples/15_node_error_handler/main.cpp:error_handler"
```
When a node stops (either from `false` return or no handler installed), it:
1. Disables its **input** channels — upstream stops pushing into dead queues.
2. Disables its **output** channels — downstream nodes receive `ChannelClosedError` on their next pop, propagating the shutdown naturally through the graph.
---
## 2. Per-node overflow callback
Fired with a timestamp each time an output push is dropped because the channel is full. The node name is known at registration so it is not included — keeping the callback zero-overhead when unused.
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:per_node_callback"
```
!!! note
The callback is purely informational — the node always continues after an overflow. To stop the node on overflow, call `node.stop()` from inside the callback.
A matching `set_closed_callback()` fires (also with just a timestamp) when the node stops due to a closed upstream channel:
```cpp
node.set_closed_callback([](std::chrono::steady_clock::time_point ts) {
std::cerr << "node stopped at t=" << ts.time_since_epoch().count() << '\n';
});
```
Each node holds two callback slots per event type — one user-set (registered above) and one injected by the network (see below). Both fire independently.
---
## 3. Network-level event handler
One callback for the whole network. Receives the node name (captured in a closure by the network at `build()` / `start()`), a `NodeEvent`, and a timestamp:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
```
`NodeEvent` values:
| Value | Meaning |
|---|---|
| `NodeEvent::Overflow` | An output push was dropped (channel full) |
| `NodeEvent::Closed` | The node stopped (crash or upstream close cascade) |
The network handler and any per-node callbacks are **independent** — both fire when set.
---
## Complete example
`examples/16_event_callbacks/main.cpp` shows a fast producer overflowing a slow consumer, with both a per-node overflow callback and a network-level event handler active simultaneously.
Node functions:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:node_fns"
```
Per-node overflow callback:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:per_node_callback"
```
Network-level event handler:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
```
+40
View File
@@ -0,0 +1,40 @@
# Examples
All C++ examples are built by default and registered as CTest smoke tests. Run them all with:
```bash
ctest --test-dir build -L examples
```
## Index
| 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 routing |
| `04_storage_policy` | `channel_storage_policy` specialisation |
| `05_error_handling` | Diagnostics handler, overflow channel stats |
| `06_watchdog` | Watchdog interval, stall detection |
| `10_static_hello_pipeline` | `StaticNetwork` + `make_network()` |
| `11_static_fanout` | `StaticNetwork` with `FanoutNode` |
| `15_node_error_handler` | `set_error_handler()` — skip or stop on exception |
| `16_event_callbacks` | `set_overflow_callback()`, `set_event_handler()` |
## OpenCV examples (optional)
Built only when OpenCV ≥ 4 is found:
| Example | What it shows |
|---|---|
| `09_opencv_cellshade` | Real-time cell-shading on webcam; `MainThreadNode` for display |
| `12_static_cellshade` | Same pipeline as a `StaticNetwork` |
| `13_debug_cellshade` | Web debug UI overlay on the cell-shading pipeline |
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.
```
+44
View File
@@ -0,0 +1,44 @@
# Fan-out & Routing
## FanoutNode
Reads one item and pushes a copy to each of N output channels. All downstream nodes receive every item.
```cpp
auto fan = make_fanout<Image, 2>(/*capacity=*/8);
net.connect("src", src.output<0>(), "fan", fan.input<0>())
.connect("fan", fan.output<0>(), "nodeA", nodeA.input<0>())
.connect("fan", fan.output<1>(), "nodeB", nodeB.input<0>());
```
If one downstream channel overflows, that output drops the item independently — the other outputs are unaffected.
See `examples/11_static_fanout`.
## RouterNode
Reads one item and pushes it to exactly one of N outputs, chosen by a selector function:
```cpp
auto router = make_router<Frame, 3>(
[](const Frame& f) -> std::size_t { return f.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>());
```
If the selector returns `>= N` the item is silently dropped.
## FilterNode
Reads one item and passes it downstream only when a predicate returns `true`:
```cpp
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>());
```
+76
View File
@@ -0,0 +1,76 @@
# Getting Started
## Requirements
| Dependency | Version | Notes |
|---|---|---|
| CMake | ≥ 3.21 | |
| C++ compiler | GCC ≥ 11, Clang ≥ 13 | C++20 required |
| nanobind | ≥ 2.1 | auto-fetched; Python ≥ 3.8 |
| Catch2 | v3 | auto-fetched for tests |
| OpenCV | ≥ 4 | optional; only for examples 09/12/13 |
## Build
```bash
cmake -B build # core + tests + C++ examples
cmake --build build --parallel
ctest --test-dir build # run all tests including example smoke tests
```
Enable Python bindings:
```bash
cmake -B build -DKPN_BUILD_PYTHON=ON
cmake --build build --parallel
```
Skip examples:
```bash
cmake -B build -DKPN_BUILD_EXAMPLES=OFF
```
## Your first pipeline
Three functions — source, transform, sink — wired into a `Network`:
```cpp
--8<-- "examples/01_hello_pipeline/main.cpp:basic_node_fns"
```
Create nodes, connect them, build and run:
```cpp
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
```
That's it. Types are inferred from function signatures. The channel between `src` and `dbl` carries `int`; the channel between `dbl` and `prn` also carries `int`. A type mismatch is a compile error.
## Named ports
For nodes with multiple inputs or outputs, name the ports for clarity:
```cpp
--8<-- "examples/02_named_ports/main.cpp:named_port_creation"
```
Wire by name instead of index:
```cpp
--8<-- "examples/02_named_ports/main.cpp:named_port_network"
```
## Multi-output nodes
Return a `std::tuple` to fan out to multiple downstream nodes:
```cpp
--8<-- "examples/03_multi_output/main.cpp:multi_output_fn"
```
Wire each tuple element to its own downstream node:
```cpp
--8<-- "examples/03_multi_output/main.cpp:fanout_network"
```
+39
View File
@@ -0,0 +1,39 @@
# KPN++
A C++20 [Kahn Process Network](https://en.wikipedia.org/wiki/Kahn_process_networks) library. Each node wraps a plain function and runs concurrently, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind.
---
## Why KPN++?
- **Zero boilerplate** — wrap any callable as a node; types flow automatically from the function signature
- **Bounded channels** — backpressure is structural, not bolted on
- **Observable** — per-node and network-level callbacks for overflow and stop events; diagnostics snapshots; optional web UI
- **Composable** — `Network` for runtime wiring, `StaticNetwork` for compile-time topology with zero overhead
---
## Quick example
```cpp
#include <kpn/kpn.hpp>
using namespace kpn;
--8<-- "examples/01_hello_pipeline/main.cpp:basic_node_fns"
int main() {
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
}
```
---
## Install & build
```bash
cmake -B build
cmake --build build --parallel
ctest --test-dir build # unit tests + example smoke tests
```
See [Getting Started](getting-started.md) for full build options.
+67
View File
@@ -0,0 +1,67 @@
# Networks
A `Network` wires nodes together at runtime using a builder chain.
## Building a network
```cpp
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
```
The builder chain:
| Method | Purpose |
|---|---|
| `.add(name, node)` | Register a node; assigns its name |
| `.connect(src, port, dst, port)` | Wire one output port to one input port |
| `.build()` | Compute topological order; inject network callbacks |
| `.start()` | Start nodes in topological order |
| `.stop()` | Stop all nodes immediately |
| `.shutdown()` | Graceful drain: stop sources first, wait for channels to empty, then stop downstream |
## Port access
Ports are accessed by index or by name:
```cpp
// By index
net.connect("src", src.output<0>(), "dst", dst.input<0>());
// By name (requires named ports)
--8<-- "examples/02_named_ports/main.cpp:named_port_network"
```
## Diagnostics
Install a diagnostics handler to receive periodic snapshots of every node and channel:
```cpp
--8<-- "examples/05_error_handling/main.cpp:diagnostics_handler"
```
Or print a full report at any time:
```cpp
net.print_diagnostics(); // writes to stderr by default
net.print_diagnostics(std::cout);
```
## Network-level event handler
Observe overflow and node-stop events across the entire network in one place:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
```
`NodeEvent` is either `NodeEvent::Overflow` (item dropped on full channel) or `NodeEvent::Closed` (node stopped due to crash or closed upstream channel). See [Error Handling & Events](error-handling.md).
## Shutdown
`net.stop()` halts immediately — all nodes stop in reverse topological order.
`net.shutdown()` drains gracefully: source nodes stop first; their output channels are polled until empty; then the next layer stops, and so on. This ensures no items are lost if downstream nodes are still consuming.
## StaticNetwork
For zero-overhead compile-time topology, see [Static Networks](static-network.md).
+81
View File
@@ -0,0 +1,81 @@
# Nodes
A node wraps any callable. Its input types are inferred from the function's parameter list; its output types from the return type.
## Node types
| Type | Thread model | Use case |
|---|---|---|
| `Node<Func>` | Dedicated thread per node | Default — simplest, most isolated |
| `PoolNode<Func>` | Shared `ThreadPool` | Many nodes, resource-bounded execution |
| `InterruptNode<Func>` | Event-driven, no thread | Camera frame ready, timer tick, socket |
| `FanoutNode<T, N>` | Dedicated thread | Broadcast one item to N outputs |
| `RouterNode<T, N>` | Dedicated thread | Route one item to one of N outputs |
| `FilterNode<T>` | Dedicated thread | Pass items matching a predicate |
## Creating nodes
All node types are created via factory functions that infer types from the callable:
```cpp
// Free function — simplest case
auto node = make_node<my_func>();
// Stateful functor (operator() is the function)
MyProcessor proc;
auto node = make_node(proc);
// Pool node — shares a ThreadPool with other nodes
auto pool = std::make_shared<ThreadPool>(4);
auto node = make_pool_node<my_func>(pool);
// Interrupt node — triggered externally
auto sched = std::make_shared<ThreadPool>(2);
auto node = make_interrupt_node<produce_frame>(sched, out<"frame">{});
camera_sdk.on_frame_ready(node.get_trigger());
```
## Channel capacity
Each node's input FIFO has a configurable capacity (default 5):
```cpp
auto node = make_node<my_func>(/*capacity=*/20);
auto node = make_pool_node<my_func>(pool, /*capacity=*/20);
```
When an upstream push would exceed capacity, `ChannelOverflowError` is thrown and the item is dropped. See [Error Handling & Events](error-handling.md) to observe and react to this.
## Source nodes
A node with no inputs is a source. It self-submits immediately on `start()` and re-submits after each execution:
```cpp
static int produce() {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
return ++counter;
}
auto src = make_node<produce>();
```
!!! tip
Source nodes must sleep or yield to avoid overflowing their output channel. The channel capacity provides the only bound.
## Sink nodes
A node with a `void` return is a sink — it consumes items without producing output:
```cpp
static void print_it(int x) { std::cout << x << '\n'; }
auto snk = make_node<print_it>();
```
## Error handler
When a node's function throws an unhandled exception, the default behaviour is to stop the node (disabling its channels so the shutdown cascades downstream). Install a handler to override:
```cpp
--8<-- "examples/15_node_error_handler/main.cpp:error_handler"
```
See [Error Handling & Events](error-handling.md) for the full picture.
+3
View File
@@ -0,0 +1,3 @@
mkdocs>=1.5
mkdocs-material>=9.5
pymdown-extensions>=10.0
+42
View File
@@ -0,0 +1,42 @@
# Shared Resources
`SharedResource<T>` arbitrates exclusive access to a resource (ONNX session, CUDA stream, serial port) across multiple nodes using a priority-based waiter queue with starvation prevention.
## Usage
```cpp
#include <kpn/shared_resource.hpp>
using namespace kpn;
SharedResource<OnnxSession> model(session_args...);
static cv::Mat run_inference(cv::Mat frame) {
// Acquires the model; releases automatically on scope exit.
auto guard = model.acquire_balanced(in_channel, out_channel);
return guard->Run(frame);
}
```
## Acquire modes
| Method | Priority |
|---|---|
| `acquire()` | Equal (fair FIFO) |
| `acquire(fn)` | Custom — `fn()` returns `float` in `[0, 1]` |
| `acquire_balanced(in_ch, out_ch)` | `input_fill × output_headroom` — highest urgency wins |
`acquire_balanced` favours nodes with full input queues and empty output queues — the node that has the most work to do and nowhere to stall wins the resource next.
## Starvation prevention
Each waiter's effective score grows with elapsed wait time (`0.05` per second by default), ensuring a low-priority node eventually gets served regardless of how frequently higher-priority nodes compete.
## Diagnostics
Register with the network for snapshot reporting:
```cpp
net.register_resource("model", &model);
```
The diagnostics table then shows acquisition count, mean wait time, and current waiter count.
+46
View File
@@ -0,0 +1,46 @@
# Static Networks
`StaticNetwork` encodes the entire topology at compile time using a `make_network()` builder. Nodes and channel types are verified statically with zero runtime overhead.
## Usage
```cpp
#include <kpn/kpn.hpp>
using namespace kpn;
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << x << '\n'; }
int main() {
auto src = make_node<produce> ();
auto dbl = make_node<double_it>();
auto prn = make_node<print_it> ();
auto net = make_network(
edge(src, src.output<0>(), dbl, dbl.input<0>()),
edge(dbl, dbl.output<0>(), prn, prn.input<0>())
);
net.set_event_handler([](std::string_view name, NodeEvent ev, auto ts) {
// same API as Network
});
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
net.stop();
}
```
See `examples/10_static_hello_pipeline` and `examples/11_static_fanout`.
## When to use
| | `Network` | `StaticNetwork` |
|---|---|---|
| Topology known at | Runtime | Compile time |
| Type checking | Runtime (`dynamic_cast`) | Compile time |
| Overhead | Minimal | Zero |
| Flexibility | Add nodes dynamically | Fixed at compile time |
For most applications `Network` is sufficient. Use `StaticNetwork` when you need the absolute minimum overhead or want compile-time topology verification.
+6
View File
@@ -7,16 +7,20 @@
//
// [produce] --int--> [double_it] --int--> [print_it]
// --8<-- [start:basic_node_fns]
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
// --8<-- [end:basic_node_fns]
int main() {
using namespace kpn;
// --8<-- [start:index_only_nodes]
auto src = make_node<produce>(5);
auto dbl = make_node<double_it>(5);
auto sink = make_node<print_it>(5);
// --8<-- [end:index_only_nodes]
// Wire channels
auto& dbl_in = dbl.input_channel<0>();
@@ -24,6 +28,7 @@ int main() {
src.set_output_channel<0>(&dbl_in);
dbl.set_output_channel<0>(&sink_in);
// --8<-- [start:network_build]
Network net;
net.add("src", src)
.add("dbl", dbl)
@@ -35,4 +40,5 @@ int main() {
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
net.stop();
// --8<-- [end:network_build]
}
+5 -3
View File
@@ -54,17 +54,18 @@ static void report(int count, std::vector<std::string> words) {
int main() {
using namespace kpn;
// --8<-- [start:named_port_creation]
// tokenise: no inputs, one named output "words"
auto tok = make_node<tokenise>(out<"words">{}, 4);
// count_words: named input "words", named outputs "count" and "words"
auto cnt = make_node<count_words>(in<"words">{}, out<"count", "words">{}, 4);
// report: two named inputs — note the function takes (int, vector<string>)
// so we need two separate input ports wired independently
// For a two-input sink we wire each output of cnt to a different input of report
// report: two named inputs
auto snk = make_node<report>(in<"count", "words">{}, 4);
// --8<-- [end:named_port_creation]
// --8<-- [start:named_port_network]
Network net;
net.add("tok", tok)
.add("cnt", cnt)
@@ -77,4 +78,5 @@ int main() {
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
net.stop();
// --8<-- [end:named_port_network]
}
+4
View File
@@ -33,6 +33,7 @@ static std::string generate() {
return pairs[gen_index++ % 5];
}
// --8<-- [start:multi_output_fn]
// 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) {
@@ -40,6 +41,7 @@ static std::tuple<std::string, std::string> parse(std::string kv) {
if (sep == std::string::npos) return {kv, ""};
return {kv.substr(0, sep), kv.substr(sep + 1)};
}
// --8<-- [end:multi_output_fn]
static void print_key(std::string key) {
std::cout << "KEY → " << key << '\n';
@@ -54,6 +56,7 @@ static void print_value(std::string value) {
int main() {
using namespace kpn;
// --8<-- [start:fanout_network]
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);
@@ -72,4 +75,5 @@ int main() {
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(600));
net.stop();
// --8<-- [end:fanout_network]
}
+2
View File
@@ -34,12 +34,14 @@ struct Tag {
int value = 0;
};
// --8<-- [start:storage_policy_spec]
// Override: store Tag by value despite being a struct
// (it's trivially copyable and small — this just makes the policy explicit)
template<>
struct kpn::channel_storage_policy<Tag> {
static constexpr bool by_value = true;
};
// --8<-- [end:storage_policy_spec]
// ── Node functions ────────────────────────────────────────────────────────────
+2
View File
@@ -45,6 +45,7 @@ int main() {
Network net;
// --8<-- [start:diagnostics_handler]
// Custom diagnostics handler — fires on the watchdog interval.
// Print a concise one-liner rather than the full table.
net.set_diagnostics_handler([](const std::vector<NodeSnapshot>& nodes,
@@ -57,6 +58,7 @@ int main() {
<< "overflows=" << c.overflows;
std::cout << '\n';
});
// --8<-- [end:diagnostics_handler]
net.set_watchdog_interval(std::chrono::milliseconds(200));
@@ -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
View 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.
}
+16
View File
@@ -8,6 +8,12 @@
#include <thread>
#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 ─────────────────────────────────────────────────────
//
// [capture] --"colour"--> [quantise] ──────────────────────────┐
@@ -32,6 +38,7 @@ static cv::Mat make_gradient(int W, int H) {
// ── Pipeline functions ────────────────────────────────────────────────────────
// --8<-- [start:capture_fn]
static std::tuple<cv::Mat, cv::Mat> capture() {
constexpr int W = 640, H = 480;
static cv::VideoCapture cap;
@@ -68,6 +75,7 @@ static std::tuple<cv::Mat, cv::Mat> capture() {
}
return {frame.clone(), frame.clone()};
}
// --8<-- [end:capture_fn]
static cv::Mat to_gray(cv::Mat bgr) {
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).
// operator() is called by step() whenever both channels have a frame ready.
// --8<-- [start:display_node]
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
kpn::in<"composite", "edges">,
cv::Mat, cv::Mat> {
@@ -141,12 +150,14 @@ private:
catch (const cv::Exception&) { return false; }
}
};
// --8<-- [end:display_node]
// ─────────────────────────────────────────────────────────────────────────────
int main() {
using namespace kpn;
// --8<-- [start:opencv_network]
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);
@@ -171,13 +182,17 @@ int main() {
.connect("comp", comp.template output<"result">(), "display", disp.template input<"composite">())
.connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">())
.build();
// --8<-- [end:opencv_network]
net.set_watchdog_interval(std::chrono::milliseconds(5000));
#ifdef KPN_WEB_DEBUG
net.set_web_debug_port(9090);
#endif
std::cout << "Cell-shading pipeline running. Press 'q' to stop.\n";
std::cout << "Web debug UI: http://localhost:9090\n";
// --8<-- [start:main_thread_step]
net.start();
// Main thread drives display — imshow/waitKey stay on the GUI thread.
@@ -186,5 +201,6 @@ int main() {
cv::waitKey(8); // yield event loop when no frame ready
net.stop();
// --8<-- [end:main_thread_step]
return 0;
}
+159
View File
@@ -0,0 +1,159 @@
// Example 14 — DebugHub with Shared Resource Token
//
// Two independent KPN networks compete for one shared inference resource
// (simulating a small GPU or single-session ONNX runtime). The DebugHub
// serves a single web UI at http://localhost:9090 with:
//
// [All Networks] — resource utilisation cards + cross-network node table
// [detect] — force-directed graph for the detection pipeline
// [classify] — force-directed graph for the classification pipeline
//
// Topology:
//
// detect pipeline:
// [source_detect] ──> [run_detect] ──> [sink_detect]
//
// classify pipeline:
// [source_classify] ──> [run_classify] ──> [sink_classify]
//
// Both [run_detect] and [run_classify] call gpu.acquire() before touching the
// simulated device. The priority-based token awards the next slot to the
// waiter that is more likely to make useful progress (higher priority score).
//
// Build: cmake -DKPN_WEB_DEBUG=ON .. && cmake --build .
// Run: ./14_debug_hub
// UI: http://localhost:9090
#ifdef KPN_WEB_DEBUG
#include <kpn/kpn.hpp>
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
using namespace kpn;
using namespace std::chrono_literals;
// ── Simulated inference device ────────────────────────────────────────────────
//
// Represents any exclusive, serialised accelerator: GPU session, ONNX runtime,
// hardware encoder, etc. Only one caller can hold it at a time.
struct GPU {
// Detection model: fast, 8 ms per frame.
int detect(int frame_id) {
std::this_thread::sleep_for(8ms);
return frame_id * 2; // synthetic "score"
}
// Classification model: heavier, 14 ms per frame.
int classify(int frame_id) {
std::this_thread::sleep_for(14ms);
return frame_id % 10; // synthetic "label"
}
};
// Global pointer so free-function nodes can reach the resource.
// In production code, capture by reference inside an ObjectNode functor instead.
static SharedResource<GPU>* g_gpu = nullptr;
// ── Detection pipeline ────────────────────────────────────────────────────────
static int source_detect() {
static std::atomic<int> id{0};
std::this_thread::sleep_for(25ms); // ~40 fps source rate
return id.fetch_add(1, std::memory_order_relaxed);
}
static int run_detect(int frame_id) {
// Higher priority: detection is latency-critical.
auto guard = g_gpu->acquire([] { return 0.7f; });
return guard->detect(frame_id);
}
static std::atomic<uint64_t> detect_out{0};
static void sink_detect(int) {
detect_out.fetch_add(1, std::memory_order_relaxed);
}
// ── Classification pipeline ───────────────────────────────────────────────────
static int source_classify() {
static std::atomic<int> id{0};
std::this_thread::sleep_for(40ms); // ~25 fps source rate
return id.fetch_add(1, std::memory_order_relaxed);
}
static int run_classify(int frame_id) {
// Lower priority: classification is best-effort.
auto guard = g_gpu->acquire([] { return 0.3f; });
return guard->classify(frame_id);
}
static std::atomic<uint64_t> classify_out{0};
static void sink_classify(int) {
classify_out.fetch_add(1, std::memory_order_relaxed);
}
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
SharedResource<GPU> gpu;
g_gpu = &gpu;
// ── Detection network ─────────────────────────────────────────────────────
auto src_det = make_node<source_detect, "source_detect">(4);
auto inf_det = make_node<run_detect, "run_detect" >(4);
auto snk_det = make_node<sink_detect, "sink_detect" >(4);
auto net_detect = make_network(
edge(src_det.output<0>(), inf_det.input<0>()),
edge(inf_det.output<0>(), snk_det.input<0>())
);
// ── Classification network ────────────────────────────────────────────────
auto src_cls = make_node<source_classify, "source_classify">(4);
auto inf_cls = make_node<run_classify, "run_classify" >(4);
auto snk_cls = make_node<sink_classify, "sink_classify" >(4);
auto net_classify = make_network(
edge(src_cls.output<0>(), inf_cls.input<0>()),
edge(inf_cls.output<0>(), snk_cls.input<0>())
);
// ── Hub — one debug server for both networks + the shared resource ─────────
DebugHub hub(9090);
hub.register_network("detect", net_detect);
hub.register_network("classify", net_classify);
hub.register_resource("gpu", &gpu);
net_detect.start();
net_classify.start();
hub.start();
std::cout << "Running — open http://localhost:9090\n"
<< "Tabs: [All Networks] [detect] [classify]\n"
<< "Press Enter to stop.\n";
std::cin.get();
net_detect.stop();
net_classify.stop();
std::cout << "\nResults:\n"
<< " detect: " << detect_out.load() << " frames\n"
<< " classify: " << classify_out.load() << " frames\n";
return 0;
}
#else // no KPN_WEB_DEBUG
#include <iostream>
int main() {
std::cerr << "This example requires KPN_WEB_DEBUG.\n"
<< "Rebuild with: cmake -DKPN_WEB_DEBUG=ON ..\n";
return 1;
}
#endif
+75
View File
@@ -0,0 +1,75 @@
// Example 15 — Per-node Error Handler
//
// Demonstrates set_error_handler() for deciding whether a network can
// continue when a node throws an exception.
//
// The "validator" node rejects even numbers by throwing std::runtime_error.
// Its error handler logs the failure and returns true (skip & continue),
// so odd numbers still flow through to the sink.
//
// Compare: a second handler (commented below) returns false instead,
// which stops the node and gracefully shuts the downstream side down.
//
// Pipeline: [source] --int--> [validator] --int--> [sink]
#include <kpn/kpn.hpp>
#include <chrono>
#include <iostream>
#include <thread>
static int counter = 0;
static int source() {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
return ++counter;
}
static int validate(int x) {
if (x % 2 == 0)
throw std::runtime_error("even number rejected: " + std::to_string(x));
return x;
}
static int received = 0;
static void sink(int x) {
std::cout << " processed: " << x << '\n';
++received;
}
int main() {
using namespace kpn;
auto src = make_node<source> ();
auto proc = make_node<validate>();
auto snk = make_node<sink> ();
// --8<-- [start:error_handler]
// Return true → skip this invocation, keep the node running.
// Return false → stop the node (downstream drains then also stops).
proc.set_error_handler([](std::string_view name, std::exception_ptr ep) {
try { std::rethrow_exception(ep); }
catch (const std::exception& e) {
std::cerr << "[" << name << "] skipping item — " << e.what() << '\n';
}
return true;
});
// --8<-- [end:error_handler]
Network net;
net.add("source", src)
.add("validator", proc)
.add("sink", snk)
.connect("source", src.output<0>(), "validator", proc.input<0>())
.connect("validator", proc.output<0>(), "sink", snk.input<0>())
.build();
std::cout << "source emits 1..N; validator rejects even numbers.\n"
<< "Error messages on stderr, accepted items on stdout.\n\n";
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(300));
net.stop();
std::cout << "\nItems accepted by sink: " << received << '\n';
}
+83
View File
@@ -0,0 +1,83 @@
// Example 16 — Event Callbacks: overflow and node-stopped signals
//
// Two complementary observation mechanisms:
//
// 1. Per-node overflow callback set_overflow_callback()
// Fired (with a timestamp) when a node's output channel is full and an
// item is dropped. Useful for targeted monitoring of a specific node.
//
// 2. Network-level event handler net.set_event_handler()
// Aggregate callback covering every node: receives the node name, a
// NodeEvent (Overflow or Closed), and a timestamp. Register once and
// observe the whole network.
//
// Pipeline: [fast_source] --int--> [slow_sink]
//
// fast_source produces at ~500 items/s; slow_sink consumes at ~20 items/s.
// The channel capacity is 3, so overflows appear within milliseconds.
#include <kpn/kpn.hpp>
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
using namespace kpn;
using namespace std::chrono;
// ── Node functions ────────────────────────────────────────────────────────────
// --8<-- [start:node_fns]
static std::atomic<int> g_seq{0};
static int fast_source() {
std::this_thread::sleep_for(milliseconds(2)); // ~500/s
return g_seq.fetch_add(1);
}
static void slow_sink(int x) {
std::this_thread::sleep_for(milliseconds(50)); // ~20/s
std::cout << " consumed: " << x << '\n';
}
// --8<-- [end:node_fns]
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
auto src = make_node<fast_source>(/*capacity=*/3);
auto snk = make_node<slow_sink> (/*capacity=*/3);
// --8<-- [start:per_node_callback]
// Per-node overflow callback — no node name needed, known at registration.
std::atomic<int> overflow_count{0};
src.set_overflow_callback([&](steady_clock::time_point ts) {
auto ms = duration_cast<milliseconds>(ts.time_since_epoch()).count();
std::cerr << "[overflow] fast_source at t=" << ms << "ms\n";
overflow_count.fetch_add(1);
});
// --8<-- [end:per_node_callback]
Network net;
// --8<-- [start:network_event_handler]
// Network-level aggregate handler — covers every node, includes node name.
net.set_event_handler([](std::string_view name, NodeEvent ev,
steady_clock::time_point ts) {
auto ms = duration_cast<milliseconds>(ts.time_since_epoch()).count();
std::string_view kind = (ev == NodeEvent::Overflow) ? "overflow" : "closed";
std::cerr << "[net:" << kind << "] node=" << name << " t=" << ms << "ms\n";
});
// --8<-- [end:network_event_handler]
net.add("source", src)
.add("sink", snk)
.connect("source", src.output<0>(), "sink", snk.input<0>())
.build()
.start();
std::this_thread::sleep_for(milliseconds(300));
net.stop();
std::cout << "\nTotal overflows observed by per-node callback: "
<< overflow_count.load() << '\n';
}
+23 -4
View File
@@ -1,8 +1,16 @@
cmake_minimum_required(VERSION 3.21)
# Build an example and register it as a CTest smoke test.
# Examples that are self-terminating (fixed sleep net.stop()) pass when
# they exit 0 within TIMEOUT seconds. OpenCV/UI examples are excluded.
function(kpn_example name)
add_executable(${name} ${name}/main.cpp)
target_link_libraries(${name} PRIVATE kpn)
add_test(NAME example_${name} COMMAND ${name})
set_tests_properties(example_${name} PROPERTIES
TIMEOUT 15
LABELS examples
)
endfunction()
kpn_example(01_hello_pipeline)
@@ -11,19 +19,30 @@ kpn_example(03_multi_output)
kpn_example(04_storage_policy)
kpn_example(05_error_handling)
kpn_example(06_watchdog)
set_tests_properties(example_06_watchdog PROPERTIES TIMEOUT 40)
kpn_example(10_static_hello_pipeline)
kpn_example(11_static_fanout)
kpn_example(15_node_error_handler)
kpn_example(16_event_callbacks)
if(KPN_WEB_DEBUG)
kpn_target_enable_web_debug(06_watchdog)
add_executable(14_debug_hub 14_debug_hub/main.cpp)
target_link_libraries(14_debug_hub PRIVATE kpn)
kpn_target_enable_web_debug(14_debug_hub)
endif()
# 07 and 08 require the Python bindings only add if built
if(KPN_BUILD_PYTHON)
# These are Python scripts, not compiled targets installed alongside kpn_python
endif()
# 07 and 08 are Python scripts no compiled target needed.
# 09 requires OpenCV only build if found
find_package(OpenCV QUIET COMPONENTS core imgproc highgui videoio)
if(OpenCV_FOUND)
# Hybrid Python example: kpn_opencv module (requires both OpenCV and nanobind)
if(KPN_BUILD_PYTHON)
nanobind_add_module(kpn_opencv 09_opencv_cellshade/kpn_opencv.cpp)
target_link_libraries(kpn_opencv PRIVATE kpn ${OpenCV_LIBS})
target_compile_definitions(kpn_opencv PRIVATE KPN_BUILD_PYTHON)
message(STATUS "KPN++ kpn_opencv Python module: building (OpenCV ${OpenCV_VERSION})")
endif()
add_executable(09_opencv_cellshade 09_opencv_cellshade/main.cpp)
target_link_libraries(09_opencv_cellshade PRIVATE kpn ${OpenCV_LIBS})
+301
View 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
+158 -55
View File
@@ -2,16 +2,30 @@
#include "diagnostics.hpp"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <string>
#include <thread>
#include <type_traits>
namespace kpn {
// ── Data size trait ───────────────────────────────────────────────────────────
// Returns the number of bytes of logical payload carried by a value.
// Defaults to sizeof(T), which is correct for PODs and fixed-size types.
// Specialize for heap-owning types (e.g. cv::Mat) to get accurate bandwidth:
//
// template<> struct kpn::ChannelDataSize<cv::Mat> {
// static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
// };
template<typename T>
struct ChannelDataSize {
static std::size_t bytes(const T&) { return sizeof(T); }
};
// ── Storage policy ────────────────────────────────────────────────────────────
template<typename T>
@@ -44,67 +58,143 @@ public:
ChannelClosedError() : std::runtime_error("channel closed") {}
};
// ── CPU pause hint ────────────────────────────────────────────────────────────
// Signals the CPU that this is a spin-wait loop, improving HT sibling throughput
// and preventing branch-predictor thrash on x86. Falls back to a compiler barrier.
[[maybe_unused]] static void spin_hint() noexcept {
#if defined(__x86_64__) || defined(__i386__)
__asm__ volatile("pause" ::: "memory");
#elif defined(__aarch64__) || defined(__arm__)
__asm__ volatile("yield" ::: "memory");
#else
std::atomic_signal_fence(std::memory_order_seq_cst);
#endif
}
// ── Channel ───────────────────────────────────────────────────────────────────
// SPSC ring buffer with atomic wait/notify and configurable spin-before-sleep.
//
// `spin_count` (constructor arg, default 200): number of pause-hint iterations
// before falling back to atomic::wait (futex). At ~20 ns/pause on x86 this is
// ~4 µs. Set to 0 to disable spinning (useful for power-constrained or
// predominantly-idle pipelines).
//
// Memory ordering contract (SPSC):
// push(): tail_.store(release) pairs with pop()'s tail_.load(acquire)
// head_.load(acquire) pairs with pop()'s head_.store(release)
// pop(): head_.store(release) pairs with push()'s head_.load(acquire)
// tail_.load(acquire) pairs with push()'s tail_.store(release)
template<typename T>
class Channel {
public:
using storage_type = channel_storage_t<T>;
explicit Channel(std::size_t capacity = 5) : capacity_(capacity) {}
explicit Channel(std::size_t capacity = 5, std::size_t spin_count = 200)
: capacity_(capacity), spin_count_(spin_count)
{
std::size_t rs = 1;
while (rs <= capacity) rs <<= 1; // smallest power-of-2 > capacity
ring_mask_ = rs - 1;
buf_ = std::make_unique<storage_type[]>(rs);
}
Channel(const Channel&) = delete;
Channel& operator=(const Channel&) = delete;
// Push a value.
// - If channel is disabled (accepting_ == false): silently drop, return immediately.
// - If channel is full: throw ChannelOverflowError.
// - If channel is disabled (accepting_ == false): silently drop.
// - If channel is full (fill >= capacity_): throw ChannelOverflowError.
void push(T value) {
if (!accepting_.load(std::memory_order_relaxed)) {
stats_.record_drop();
return;
}
std::unique_lock lock(mutex_);
if (!accepting_.load(std::memory_order_relaxed)) {
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
const std::size_t t = tail_.load(std::memory_order_relaxed);
const std::size_t h = head_.load(std::memory_order_acquire);
if (!accepting_.load(std::memory_order_acquire)) {
stats_.record_drop();
return;
}
if (queue_.size() >= capacity_) {
if (t - h >= capacity_) {
stats_.record_overflow();
throw ChannelOverflowError(capacity_);
}
queue_.push(make_storage(std::move(value)));
stats_.record_push(queue_.size());
lock.unlock();
cv_.notify_one();
const bool was_empty = (t == h);
buf_[t & ring_mask_] = make_storage(std::move(value));
tail_.store(t + 1, std::memory_order_release);
stats_.record_push(t - h + 1, data_bytes);
wake_.fetch_add(1, std::memory_order_release);
wake_.notify_one();
if (was_empty && push_callback_)
push_callback_();
}
// Blocking pop. Unblocks when an item is available or the channel is disabled.
// Throws ChannelClosedError if disabled and queue is empty.
// Blocking pop. Returns when an item is available.
// Throws ChannelClosedError if the channel is disabled (regardless of fill).
T pop() {
std::unique_lock lock(mutex_);
cv_.wait(lock, [this] {
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
});
if (queue_.empty())
throw ChannelClosedError{};
T value = extract(std::move(queue_.front()));
queue_.pop();
stats_.record_pop();
return value;
for (;;) {
// Snapshot wake_ BEFORE reading tail_ to prevent lost wakeups.
const uint32_t w = wake_.load(std::memory_order_relaxed);
const std::size_t h = head_.load(std::memory_order_relaxed);
std::size_t t = tail_.load(std::memory_order_acquire);
// If empty, spin before sleeping: avoids the futex when the next item
// arrives within the spin window (~4 µs at default spin_count=200 on x86).
if (h == t) {
if (!accepting_.load(std::memory_order_acquire))
throw ChannelClosedError{};
for (std::size_t s = 0; s < spin_count_; ++s) {
spin_hint();
t = tail_.load(std::memory_order_acquire);
if (t != h) break;
if (!accepting_.load(std::memory_order_relaxed))
throw ChannelClosedError{};
}
if (h == t) {
// Still empty after spin — sleep until push() or disable() fires.
// Re-check tail after loading w to guard against a lost wakeup.
if (tail_.load(std::memory_order_acquire) != h) continue;
wake_.wait(w, std::memory_order_relaxed);
continue;
}
}
// Item available (found immediately or during spin).
if (!accepting_.load(std::memory_order_acquire))
throw ChannelClosedError{};
T value = extract(std::move(buf_[h & ring_mask_]));
head_.store(h + 1, std::memory_order_release);
stats_.record_pop();
return value;
}
}
// Non-blocking pop with timeout. For watchdog/display use only — not used in run_loop.
// Non-blocking pop with timeout. For watchdog/display use only.
bool try_pop(T& out, std::chrono::milliseconds timeout) {
std::unique_lock lock(mutex_);
if (!cv_.wait_for(lock, timeout, [this] {
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
}))
return false;
if (queue_.empty())
return false;
out = extract(std::move(queue_.front()));
queue_.pop();
const auto deadline = std::chrono::steady_clock::now() + timeout;
for (;;) {
if (try_pop_now(out)) return true;
if (!accepting_.load(std::memory_order_relaxed)) return false;
if (std::chrono::steady_clock::now() >= deadline) return false;
std::this_thread::sleep_for(std::chrono::microseconds(50));
}
}
// Immediate non-blocking pop. Returns false if the ring is empty.
bool try_pop_now(T& out) {
const std::size_t h = head_.load(std::memory_order_relaxed);
if (h == tail_.load(std::memory_order_acquire)) return false;
out = extract(std::move(buf_[h & ring_mask_]));
head_.store(h + 1, std::memory_order_release);
stats_.record_pop();
return true;
}
@@ -114,38 +204,44 @@ public:
accepting_.store(true, std::memory_order_relaxed);
}
// Disable the channel: drop all queued items, unblock any waiting pop().
// Called by consumer node on stop(). Producer push() will silently drop after this.
// Disable the channel: stop accepting new pushes, unblock any waiting pop().
// Items already in the ring are abandoned and freed when the Channel is destroyed.
void disable() {
accepting_.store(false, std::memory_order_relaxed);
{
std::lock_guard lock(mutex_);
while (!queue_.empty()) queue_.pop();
}
cv_.notify_all();
accepting_.store(false, std::memory_order_release);
wake_.fetch_add(1, std::memory_order_release);
wake_.notify_all();
}
std::size_t size() const {
std::lock_guard lock(mutex_);
return queue_.size();
// Register a callback fired when the queue transitions empty→non-empty.
void set_push_callback(std::function<void()> cb) {
push_callback_ = std::move(cb);
}
// Size derived lazily from ring indices — no separate counter on the hot path.
std::size_t size() const {
return tail_.load(std::memory_order_relaxed)
- head_.load(std::memory_order_relaxed);
}
std::size_t approx_size() const { return size(); }
std::size_t capacity() const { return capacity_; }
bool is_accepting() const { return accepting_.load(std::memory_order_relaxed); }
const ChannelStats& stats() const { return stats_; }
ChannelSnapshot snapshot(const std::string& name) const {
std::lock_guard lock(mutex_);
const std::size_t t = tail_.load(std::memory_order_relaxed);
const std::size_t h = head_.load(std::memory_order_relaxed);
return {
name,
capacity_,
queue_.size(),
t - h,
stats_.peak_fill.load(std::memory_order_relaxed),
stats_.pushes.load(std::memory_order_relaxed),
stats_.bytes_pushed.load(std::memory_order_relaxed),
stats_.drops.load(std::memory_order_relaxed),
stats_.overflows.load(std::memory_order_relaxed),
stats_.pops.load(std::memory_order_relaxed),
sizeof(T), // payload bytes — sizeof(T) regardless of storage policy
sizeof(T),
};
}
@@ -164,12 +260,19 @@ private:
return *s;
}
const std::size_t capacity_;
std::queue<storage_type> queue_;
std::atomic<bool> accepting_{true};
mutable std::mutex mutex_;
std::condition_variable cv_;
ChannelStats stats_;
const std::size_t capacity_;
const std::size_t spin_count_;
std::size_t ring_mask_;
std::unique_ptr<storage_type[]> buf_;
std::function<void()> push_callback_;
ChannelStats stats_;
// Separate cache lines: head_ is written only by the consumer;
// tail_ and wake_ are written only by the producer.
alignas(64) std::atomic<std::size_t> head_{0};
alignas(64) std::atomic<std::size_t> tail_{0};
std::atomic<uint32_t> wake_{0};
std::atomic<bool> accepting_{true};
};
// ── Channel probe — type-erased snapshot accessor ─────────────────────────────
+456
View File
@@ -0,0 +1,456 @@
#pragma once
// Only active when KPN_WEB_DEBUG is defined.
#ifdef KPN_WEB_DEBUG
#include "diagnostics.hpp"
#include "web_debug.hpp"
#include <functional>
#include <memory>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
namespace kpn {
// ── Hub HTML ──────────────────────────────────────────────────────────────────
// Multi-tab UI: one tab per registered network (force-directed graph) +
// an "All Networks" tab showing shared resource cards and a cross-network
// node table.
static const char* HUB_HTML = R"html(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>KPN++ Debug Hub</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#1a1a2e;color:#eee;font-family:monospace}
#hdr{display:flex;align-items:center;padding:0 16px;background:#16213e;
border-bottom:1px solid #0f3460;height:44px;gap:8px;overflow-x:auto}
#hdr h1{margin:0;font-size:16px;color:#e94560;white-space:nowrap;margin-right:12px}
#tab-bar{display:flex;gap:2px;flex:1}
.tab{padding:4px 14px;border:none;background:#0f3460;color:#aaa;
cursor:pointer;font-family:monospace;font-size:11px;border-radius:2px;white-space:nowrap}
.tab.active{background:#e94560;color:#fff}
.tab:hover:not(.active){background:#1e3a6e;color:#eee}
#status{font-size:10px;color:#555;white-space:nowrap}
.panel{display:none}
.panel.active{display:block}
/* ── All Networks tab ─────────────────────────────────────── */
#panel-all{height:calc(100vh - 44px);overflow-y:auto;padding:16px;
display:none;gap:16px;grid-template-columns:300px 1fr}
#panel-all.active{display:grid;align-content:start}
#panel-all h2{font-size:11px;color:#e94560;margin:0 0 8px;
letter-spacing:1px;text-transform:uppercase}
#res-col{grid-column:1}
.res-card{background:#16213e;border:1px solid #0f3460;border-radius:4px;
padding:10px 12px;margin-bottom:8px}
.res-head{display:flex;justify-content:space-between;align-items:center}
.res-name{font-size:12px}
.badge{font-size:9px;padding:2px 6px;border-radius:2px}
.held{background:#e94560}.free{background:#4CAF50;color:#111}
.res-meta{font-size:9px;color:#666;margin-top:5px;display:flex;gap:12px;flex-wrap:wrap}
.bar-wrap{height:3px;background:#0f3460;border-radius:2px;margin-top:7px}
.bar{height:3px;border-radius:2px;transition:width 0.4s}
#nodes-col{grid-column:2;overflow-y:auto;max-height:calc(100vh - 76px)}
table{width:100%;border-collapse:collapse;font-size:10px}
th{padding:4px 8px;color:#555;border-bottom:1px solid #0f3460;text-align:left;
position:sticky;top:0;background:#1a1a2e;z-index:1}
td{padding:2px 8px;border-bottom:1px solid #16213e}
tr:hover td{background:#16213e}
.ntag{font-size:9px;background:#0f3460;padding:1px 4px;border-radius:2px;color:#4CAF50}
/* ── Per-network graph panels ─────────────────────────────── */
.graph-panel{width:100vw;height:calc(100vh - 44px)}
svg.net{width:100%;height:100%}
.node circle{stroke:#fff;stroke-width:1.5px}
.node text{font-size:11px;fill:#eee;pointer-events:none;text-anchor:middle}
.node .st{font-size:9px;fill:#aaa}
.link{fill:none;stroke-width:2px}
.lbl{font-size:9px;fill:#ccc}
#tip{position:absolute;background:#0f3460;border:1px solid #e94560;border-radius:4px;
padding:8px 12px;font-size:11px;pointer-events:none;display:none;
white-space:pre;line-height:1.6}
</style>
</head>
<body>
<div id="hdr">
<h1>KPN++ Debug Hub</h1>
<div id="tab-bar"></div>
<span id="status">connecting</span>
</div>
<div id="panels">
<div id="panel-all" class="panel"></div>
</div>
<div id="tip"></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const R = 28;
const tip = d3.select('#tip');
const nc = ema => ema>100?'#e94560':ema>50?'#e07040':ema>10?'#f0c040':'#4CAF50';
const ec = pct => pct>=80?'#e94560':pct>=50?'#f0c040':'#4CAF50';
const ea = pct => pct>=80?'url(#a2)':pct>=50?'url(#a1)':'url(#a0)';
// ── Tab management ────────────────────────────────────────────────────────────
let activeTab = null;
function ensureTab(id, label) {
if (document.getElementById('tab-' + id)) return;
const b = document.createElement('button');
b.className = 'tab'; b.id = 'tab-' + id; b.textContent = label;
b.onclick = () => showTab(id);
document.getElementById('tab-bar').appendChild(b);
}
function showTab(id) {
activeTab = id;
document.querySelectorAll('.tab').forEach(b =>
b.classList.toggle('active', b.id === 'tab-' + id));
document.querySelectorAll('.panel').forEach(p =>
p.classList.toggle('active', p.id === 'panel-' + id));
}
// ── All Networks tab ──────────────────────────────────────────────────────────
function renderAll(data) {
const panel = document.getElementById('panel-all');
// Resources column
let rhtml = '<div id="res-col"><h2>Shared Resources</h2>';
if (!data.resources || !data.resources.length)
rhtml += '<div style="color:#444;font-size:11px">None registered</div>';
for (const r of (data.resources || [])) {
const wpct = Math.min(100, r.avg_wait_ms).toFixed(1);
const bc = r.current_waiters > 0 ? '#e94560' : '#4CAF50';
rhtml += `<div class="res-card">
<div class="res-head">
<span class="res-name">${r.name}</span>
<span class="badge ${r.held ? 'held' : 'free'}">${r.held ? 'HELD' : 'free'}</span>
</div>
<div class="res-meta">
<span>avg wait ${r.avg_wait_ms.toFixed(1)} ms</span>
<span>waiters ${r.current_waiters} / peak ${r.peak_waiters}</span>
<span>${r.acquisitions} acq</span>
</div>
<div class="bar-wrap">
<div class="bar" style="width:${wpct}%;background:${bc}"></div>
</div>
</div>`;
}
rhtml += '</div>';
// Nodes column — all networks in one table
let rows = '';
for (const net of data.networks) {
for (const n of net.nodes) {
rows += `<tr>
<td><span class="ntag">${net.name}</span></td>
<td>${n.id}</td>
<td>${n.fps.toFixed(1)}</td>
<td>${n.ema_exec_ms.toFixed(2)}</td>
<td>${n.max_exec_ms.toFixed(2)}</td>
<td>${n.blocked_ms.toFixed(2)}</td>
<td>${n.cpu_util_pct.toFixed(1)}</td>
</tr>`;
}
}
const thtml = `<div id="nodes-col"><h2>All Nodes</h2>
<table>
<tr><th>Network</th><th>Node</th><th>fps</th>
<th>exec ema (ms)</th><th>exec max (ms)</th>
<th>blocked (ms)</th><th>cpu %</th></tr>
${rows}
</table></div>`;
panel.innerHTML = rhtml + thtml;
}
// ── Per-network graph ─────────────────────────────────────────────────────────
const nets = {};
function initNet(netData) {
const name = netData.name;
const div = document.createElement('div');
div.id = 'panel-' + name;
div.className = 'panel graph-panel';
document.getElementById('panels').appendChild(div);
const svg = d3.select(div).append('svg').attr('class', 'net');
const W = () => div.clientWidth || window.innerWidth;
const H = () => div.clientHeight || (window.innerHeight - 44);
const defs = svg.append('defs');
['#4CAF50','#f0c040','#e94560'].forEach((col, i) =>
defs.append('marker').attr('id','a'+i)
.attr('viewBox','0 -5 10 10').attr('refX',10).attr('refY',0)
.attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
.append('path').attr('d','M0,-5L10,0L0,5').attr('fill',col));
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
const nodes = netData.nodes.map(n => ({...n, x: W()/2, y: H()/2}));
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const links = netData.edges
.map(e => ({...e, source: byId[e.source], target: byId[e.target]}))
.filter(e => e.source && e.target);
const sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).distance(150).strength(0.5))
.force('charge', d3.forceManyBody().strength(-350))
.force('center', d3.forceCenter(W()/2, H()/2))
.force('collide', d3.forceCollide(R + 18))
.on('tick', tick);
const lsel = g.append('g').selectAll('line').data(links).join('line')
.attr('class','link')
.attr('stroke', d => ec(d.fill_pct))
.attr('marker-end', d => ea(d.fill_pct));
const llbl = g.append('g').selectAll('text').data(links).join('text')
.attr('class','lbl').text(d => `${d.fill_pct.toFixed(0)}%`);
const ng = g.append('g').selectAll('g').data(nodes).join('g').attr('class','node')
.call(d3.drag()
.on('start',(e,d)=>{ if(!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d)=>{ d.fx=e.x; d.fy=e.y; })
.on('end', (e,d)=>{ if(!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
ng.append('circle').attr('r', R).attr('fill', d => nc(d.ema_exec_ms));
ng.append('text').attr('dy', 4).text(d => d.id);
ng.append('text').attr('class','st').attr('dy', 18)
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
ng.on('mousemove', (e,d) =>
tip.style('display','block')
.style('left',(e.pageX+12)+'px').style('top',(e.pageY+12)+'px')
.text(`${d.id}\nframes: ${d.frames} fps: ${d.fps.toFixed(2)}\n` +
`exec ema: ${d.ema_exec_ms.toFixed(2)}ms max: ${d.max_exec_ms.toFixed(2)}ms\n` +
`blocked: ${d.blocked_ms.toFixed(2)}ms cpu: ${d.cpu_util_pct.toFixed(1)}%`))
.on('mouseleave', () => tip.style('display','none'));
g.selectAll('.link')
.on('mousemove', (e,d) =>
tip.style('display','block')
.style('left',(e.pageX+12)+'px').style('top',(e.pageY+12)+'px')
.text(`${d.name}\nfill: ${d.fill_pct.toFixed(1)}% peak: ${d.peak_pct.toFixed(1)}%\n` +
`cap: ${d.capacity} pushes: ${d.pushes} drops: ${d.drops}\n` +
`bandwidth: ${(d.bw_mbs||0).toFixed(2)} MB/s`))
.on('mouseleave', () => tip.style('display','none'));
function tick() {
const w = W(), h = H();
nodes.forEach(d => {
d.x = Math.max(R, Math.min(w - R, d.x));
d.y = Math.max(R, Math.min(h - R, d.y));
});
lsel
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => { const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y,
dist=Math.sqrt(dx*dx+dy*dy)||1;
return d.target.x-(dx/dist)*(R+8); })
.attr('y2', d => { const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y,
dist=Math.sqrt(dx*dx+dy*dy)||1;
return d.target.y-(dy/dist)*(R+8); });
llbl.attr('x', d => (d.source.x+d.target.x)/2)
.attr('y', d => (d.source.y+d.target.y)/2 - 6);
ng.attr('transform', d => `translate(${d.x},${d.y})`);
}
nets[name] = {nodes, links, sim, ng, lsel, llbl};
}
function updateNet(netData) {
const st = nets[netData.name];
if (!st) return;
const byId = Object.fromEntries(netData.nodes.map(n => [n.id, n]));
st.nodes.forEach(n => {
const f = byId[n.id];
if (f) Object.assign(n, {frames:f.frames, ema_exec_ms:f.ema_exec_ms,
max_exec_ms:f.max_exec_ms, blocked_ms:f.blocked_ms, fps:f.fps,
total_cpu_ms:f.total_cpu_ms, cpu_util_pct:f.cpu_util_pct});
});
netData.edges.forEach((e,i) => {
if (st.links[i]) Object.assign(st.links[i], {fill_pct:e.fill_pct,
peak_pct:e.peak_pct, pushes:e.pushes, drops:e.drops,
overflows:e.overflows, current:e.current, bw_mbs:e.bw_mbs});
});
st.ng.select('circle').attr('fill', d => nc(d.ema_exec_ms));
st.ng.select('.st').text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
st.lsel.attr('stroke', d => ec(d.fill_pct)).attr('marker-end', d => ea(d.fill_pct));
st.llbl.text(d => `${d.fill_pct.toFixed(0)}%`);
}
// ── Poll loop ─────────────────────────────────────────────────────────────────
let init = false;
async function poll() {
try {
const r = await fetch('/api/snapshot');
if (!r.ok) throw new Error(r.status);
const data = await r.json();
if (!init) {
ensureTab('all', 'All Networks');
data.networks.forEach(net => ensureTab(net.name, net.name));
showTab('all');
data.networks.forEach(initNet);
init = true;
}
renderAll(data);
data.networks.forEach(updateNet);
document.getElementById('status').textContent =
`${new Date().toLocaleTimeString()} · ${data.networks.length} nets · ${(data.resources||[]).length} resources`;
} catch(e) {
document.getElementById('status').textContent = 'error: ' + e;
}
}
poll();
setInterval(poll, 500);
window.addEventListener('resize', () =>
Object.values(nets).forEach(st =>
st.sim.force('center', d3.forceCenter(
(document.getElementById('panel-' + Object.keys(nets).find(k => nets[k] === st))?.clientWidth || window.innerWidth) / 2,
(document.getElementById('panel-' + Object.keys(nets).find(k => nets[k] === st))?.clientHeight || window.innerHeight - 44) / 2
)).alpha(0.1).restart()));
</script>
</body>
</html>
)html";
// ── DebugHub ──────────────────────────────────────────────────────────────────
class DebugHub {
public:
explicit DebugHub(uint16_t port = 9090) : port_(port) {}
~DebugHub() { stop(); }
DebugHub(const DebugHub&) = delete;
DebugHub& operator=(const DebugHub&) = delete;
// Register a network. Disables that network's own web server so the hub
// becomes the single debug endpoint. Call before network.start().
template<typename Net>
void register_network(const std::string& name, Net& net) {
net.disable_web_server();
networks_.push_back({name, [&net, name] {
auto s = net.network_snapshot();
s.name = name;
return s;
}});
}
// Register a shared resource — appears in the "All Networks" resource panel.
void register_resource(const std::string& name, IResourceProbe* probe) {
resources_.emplace_back(name, probe);
}
void start() {
server_ = std::make_unique<web_debug::WebDebugServer>(
port_,
[this] { return build_json(); },
HUB_HTML);
server_->start();
std::cerr << "[kpn] hub debug UI: http://localhost:" << port_ << "\n";
}
void stop() { if (server_) server_->stop(); }
private:
// Serialise nodes array for one network snapshot
static void write_nodes(std::ostream& o, const NetworkSnapshot& s) {
o << "[";
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
const auto& n = s.nodes[i];
if (i) o << ',';
o << "{\"id\":\"" << web_debug::escape_json(n.name) << "\""
<< ",\"frames\":" << n.frames_processed
<< ",\"ema_exec_ms\":" << n.ema_exec_ms
<< ",\"max_exec_ms\":" << n.max_exec_ms
<< ",\"blocked_ms\":" << n.total_blocked_ms
<< ",\"fps\":" << n.throughput_fps
<< ",\"total_cpu_ms\":" << n.total_cpu_ms
<< ",\"cpu_util_pct\":" << n.cpu_util_pct
<< "}";
}
o << "]";
}
// Serialise edges array for one network snapshot
static void write_edges(std::ostream& o, const NetworkSnapshot& s) {
o << "[";
for (std::size_t i = 0; i < s.channels.size(); ++i) {
const auto& c = s.channels[i];
if (i) o << ',';
auto [src, dst] = web_debug::parse_edge_name(c.name);
o << "{\"name\":\"" << web_debug::escape_json(c.name) << "\""
<< ",\"source\":\"" << web_debug::escape_json(src) << "\""
<< ",\"target\":\"" << web_debug::escape_json(dst) << "\""
<< ",\"capacity\":" << c.capacity
<< ",\"current\":" << c.current_fill
<< ",\"fill_pct\":" << c.fill_pct()
<< ",\"peak_pct\":" << c.peak_pct()
<< ",\"pushes\":" << c.pushes
<< ",\"drops\":" << c.drops
<< ",\"overflows\":" << c.overflows
<< ",\"item_bytes\":" << c.item_bytes
<< ",\"bw_mbs\":" << c.bandwidth_mbs(s.elapsed_s)
<< "}";
}
o << "]";
}
std::string build_json() const {
std::ostringstream o;
o << std::fixed;
o.precision(2);
o << "{\"networks\":[";
for (std::size_t i = 0; i < networks_.size(); ++i) {
if (i) o << ',';
auto s = networks_[i].fn();
o << "{\"name\":\"" << web_debug::escape_json(networks_[i].name) << "\""
<< ",\"nodes\":"; write_nodes(o, s);
o << ",\"edges\":"; write_edges(o, s);
o << "}";
}
o << "],\"resources\":[";
for (std::size_t i = 0; i < resources_.size(); ++i) {
if (i) o << ',';
const auto r = resources_[i].second->snapshot(resources_[i].first);
o << "{\"name\":\"" << web_debug::escape_json(r.name) << "\""
<< ",\"acquisitions\":" << r.acquisitions
<< ",\"avg_wait_ms\":" << r.avg_wait_ms
<< ",\"peak_waiters\":" << r.peak_waiters
<< ",\"current_waiters\":" << r.current_waiters
<< ",\"held\":" << (r.held ? "true" : "false")
<< "}";
}
o << "]}";
return o.str();
}
struct Entry {
std::string name;
std::function<NetworkSnapshot()> fn;
};
uint16_t port_;
std::vector<Entry> networks_;
std::vector<std::pair<std::string,IResourceProbe*>> resources_;
std::unique_ptr<web_debug::WebDebugServer> server_;
};
} // namespace kpn
#endif // KPN_WEB_DEBUG
+64 -8
View File
@@ -15,6 +15,7 @@ using duration_t = std::chrono::duration<double, std::milli>; // milliseconds
struct ChannelStats {
std::atomic<uint64_t> pushes{0};
std::atomic<uint64_t> bytes_pushed{0};
std::atomic<uint64_t> drops{0};
std::atomic<uint64_t> overflows{0};
std::atomic<uint64_t> pops{0};
@@ -24,8 +25,9 @@ struct ChannelStats {
ChannelStats(const ChannelStats&) = delete;
ChannelStats& operator=(const ChannelStats&) = delete;
void record_push(std::size_t current_fill) {
void record_push(std::size_t current_fill, std::size_t data_bytes) {
pushes.fetch_add(1, std::memory_order_relaxed);
bytes_pushed.fetch_add(data_bytes, std::memory_order_relaxed);
std::size_t prev = peak_fill.load(std::memory_order_relaxed);
while (current_fill > prev &&
!peak_fill.compare_exchange_weak(prev, current_fill,
@@ -54,6 +56,12 @@ struct NodeStats {
// blocked on mutexes/channels. Sampled once per frame.
std::atomic<int64_t> total_cpu_us{0}; // cumulative CPU µs consumed
// Pool scheduling stats — only meaningful for PoolNode / InterruptNode.
// exec_start_us: wall-clock µs when fire_once began; 0 when idle.
// Used by the watchdog to detect hung nodes (elapsed > max_exec_time).
std::atomic<int64_t> queue_wait_us{0}; // cumulative µs spent in pool queue
std::atomic<int64_t> exec_start_us{0}; // non-zero while fire_once is running
NodeStats() = default;
NodeStats(const NodeStats&) = delete;
NodeStats& operator=(const NodeStats&) = delete;
@@ -71,6 +79,11 @@ struct NodeStats {
+ static_cast<int64_t>(ts.tv_nsec) / 1'000;
}
void record_queue_wait(duration_t wait) {
int64_t us = static_cast<int64_t>(wait.count() * 1000.0);
if (us > 0) queue_wait_us.fetch_add(us, std::memory_order_relaxed);
}
void record_exec(duration_t exec_time, duration_t blocked_time,
const struct timespec& cpu_before, const struct timespec& cpu_after) {
frames_processed.fetch_add(1, std::memory_order_relaxed);
@@ -105,10 +118,11 @@ struct ChannelSnapshot {
std::size_t current_fill;
std::size_t peak_fill;
uint64_t pushes;
uint64_t bytes_pushed; // actual bytes accumulated via channel_data_size<T>
uint64_t drops;
uint64_t overflows;
uint64_t pops;
std::size_t item_bytes; // sizeof(T) for the stored type — set by Channel<T>
std::size_t item_bytes; // sizeof(T) — nominal struct size, not necessarily data size
double fill_pct() const {
return capacity ? 100.0 * current_fill / capacity : 0.0;
@@ -116,10 +130,10 @@ struct ChannelSnapshot {
double peak_pct() const {
return capacity ? 100.0 * peak_fill / capacity : 0.0;
}
// Bandwidth in MB/s: bytes transferred / elapsed seconds
// Bandwidth in MB/s: actual bytes transferred / elapsed seconds
double bandwidth_mbs(double elapsed_s) const {
if (elapsed_s <= 0.0 || item_bytes == 0) return 0.0;
return static_cast<double>(pushes * item_bytes) / elapsed_s / 1e6;
if (elapsed_s <= 0.0) return 0.0;
return static_cast<double>(bytes_pushed) / elapsed_s / 1e6;
}
};
@@ -128,10 +142,52 @@ struct NodeSnapshot {
uint64_t frames_processed;
double ema_exec_ms;
double max_exec_ms;
double total_blocked_ms;
double total_blocked_ms; // ThreadPerNode: time blocked in channel pop
double throughput_fps;
double total_cpu_ms; // cumulative CPU time consumed by this node's thread
double cpu_util_pct; // exec_ms / (exec_ms + blocked_ms) * 100
double total_cpu_ms; // cumulative CPU time consumed by this node's thread
double cpu_util_pct; // exec_ms / (exec_ms + blocked_ms) * 100
double queue_wait_ms{0}; // PoolNode: cumulative time spent in pool queue
};
// ── Pool statistics + snapshot ────────────────────────────────────────────────
struct PoolSnapshot {
std::string name;
std::size_t thread_count;
std::size_t queue_depth; // tasks waiting in the priority queue
std::size_t active_count; // tasks currently executing
uint64_t tasks_submitted;
uint64_t tasks_completed;
};
struct IPoolProbe {
virtual ~IPoolProbe() = default;
virtual PoolSnapshot snapshot(const std::string& name) const = 0;
};
// ── Cross-network snapshot (used by DebugHub) ─────────────────────────────────
struct NetworkSnapshot {
std::string name;
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
double elapsed_s;
};
// ── Resource statistics + snapshot ───────────────────────────────────────────
struct ResourceSnapshot {
std::string name;
uint64_t acquisitions;
double avg_wait_ms;
uint64_t peak_waiters;
uint64_t current_waiters;
bool held;
};
struct IResourceProbe {
virtual ~IResourceProbe() = default;
virtual ResourceSnapshot snapshot(const std::string& name) const = 0;
};
} // namespace kpn
+2 -20
View File
@@ -1,7 +1,7 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "node.hpp"
#include "inode.hpp"
#include "port.hpp"
#include "traits.hpp"
@@ -15,24 +15,6 @@
namespace kpn {
namespace detail {
// Produces std::tuple<T, T, ..., T> with N repetitions — used so that
// Network::connect can do its normal return_tuple type-check against FanoutNode.
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
struct repeat_tuple;
template<typename T, std::size_t N, std::size_t... Is>
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
template<std::size_t> using always_T = T;
using type = std::tuple<always_T<Is>...>;
};
template<typename T, std::size_t N>
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
} // namespace detail
// ── FanoutNode ────────────────────────────────────────────────────────────────
//
// Reads one item from its single input channel and pushes a copy to each of
@@ -48,7 +30,7 @@ template<typename T, std::size_t N, std::size_t Id = 0>
class FanoutNode : public INode {
public:
using args_tuple = std::tuple<T>;
using return_tuple = detail::repeat_tuple_t<T, N>;
using return_tuple = repeat_tuple_t<T, N>;
using return_raw = return_tuple;
static constexpr std::size_t input_count = 1;
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "diagnostics.hpp"
#include <chrono>
#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)>;
// Lightweight timestamp-only callback fired on per-node events.
// The node name is known at registration time so it is not included here.
using NodeEventCallback = std::function<void(std::chrono::steady_clock::time_point)>;
// Event types reported to the network-level aggregate callback.
enum class NodeEvent { Overflow, Closed };
// ── 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;
// Network-injected callbacks (slot 1 of each node's callback array).
// Default no-ops; overridden by PoolNode, PoolObjectNode, InterruptNode.
virtual void set_network_overflow_callback(NodeEventCallback) {}
virtual void set_network_closed_callback(NodeEventCallback) {}
// 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
+279
View File
@@ -0,0 +1,279 @@
#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; }
void set_overflow_callback(NodeEventCallback cb) { event_callbacks_[0] = std::move(cb); }
void set_network_overflow_callback(NodeEventCallback cb) override { event_callbacks_[1] = std::move(cb); }
void set_closed_callback(NodeEventCallback cb) { closed_callbacks_[0] = std::move(cb); }
void set_network_closed_callback(NodeEventCallback cb) override { closed_callbacks_[1] = std::move(cb); }
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&) {
fire_callbacks(event_callbacks_);
} catch (...) {
if (!error_handler_ || !error_handler_(name_, std::current_exception()))
fatal = true;
}
stats_.exec_start_us.store(0, std::memory_order_relaxed);
if (fatal) {
fire_callbacks(closed_callbacks_);
disable_outputs(std::make_index_sequence<output_count>{});
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>{}));
template<std::size_t... Is>
void disable_outputs(std::index_sequence<Is...>) {
auto disable_one = [](auto* ch) { if (ch) ch->disable(); };
(disable_one(std::get<Is>(output_channels_)), ...);
}
static void fire_callbacks(const std::array<NodeEventCallback, 2>& cbs) {
const auto ts = std::chrono::steady_clock::now();
for (auto& cb : cbs) if (cb) cb(ts);
}
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};
NodeStats stats_;
NodeErrorHandler error_handler_;
std::chrono::milliseconds max_exec_time_{0};
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
std::array<NodeEventCallback, 2> closed_callbacks_{};
};
// ── 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
+7
View File
@@ -5,8 +5,15 @@
#include "traits.hpp"
#include "channel.hpp"
#include "port.hpp"
#include "inode.hpp"
#include "scheduler.hpp"
#include "pool_node.hpp"
#include "interrupt_node.hpp"
#include "node.hpp"
#include "fanout.hpp"
#include "branch.hpp"
#include "shared_resource.hpp"
#include "static_network.hpp"
#include "debug_hub.hpp"
#include "main_thread_node.hpp"
#include "network.hpp"
+1 -1
View File
@@ -2,7 +2,7 @@
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "node.hpp" // INode
#include "inode.hpp"
#include "port.hpp"
#include <atomic>
+137 -7
View File
@@ -1,6 +1,6 @@
#pragma once
#include "diagnostics.hpp"
#include "node.hpp"
#include "inode.hpp"
#include "port.hpp"
#ifdef KPN_WEB_DEBUG
@@ -44,6 +44,9 @@ public:
using DiagnosticsHandler =
std::function<void(const std::vector<NodeSnapshot>&,
const std::vector<ChannelSnapshot>&)>;
using EventHandler =
std::function<void(std::string_view node_name, NodeEvent,
std::chrono::steady_clock::time_point)>;
// ── Builder API ───────────────────────────────────────────────────────────
@@ -111,6 +114,19 @@ public:
for (auto& [name, _] : nodes_)
if (color[name] == 0)
dfs(name, color);
if (event_handler_) {
for (auto& name : topo_) {
auto* node = nodes_.at(name);
node->set_network_overflow_callback(
[this, n = name](auto ts) {
event_handler_(n, NodeEvent::Overflow, ts);
});
node->set_network_closed_callback(
[this, n = name](auto ts) {
event_handler_(n, NodeEvent::Closed, ts);
});
}
}
return *this;
}
@@ -126,14 +142,17 @@ public:
web_debug_port_,
[this]() {
auto s = collect_snapshots();
return web_debug::to_json(s.nodes, s.channels, s.elapsed_s);
return web_debug::to_json(s.nodes, s.channels, {}, s.elapsed_s, s.pools);
});
web_server_->start();
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
#endif
}
void stop() override {
void stop() override { halt(); }
// halt(): immediate stop — broadcasts disable to all channels and joins threads.
void halt() override {
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
#endif
@@ -142,6 +161,37 @@ public:
nodes_.at(*it)->stop();
}
// shutdown(): graceful drain in topological order.
// Stops source nodes first, polls until their output channels drain to zero,
// then stops the next layer, and so on.
void shutdown() override {
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
#endif
stop_watchdog();
// Identify which nodes have no incoming edges (sources).
std::map<std::string, std::size_t> in_degree;
for (auto& [name, _] : nodes_) in_degree[name] = 0;
for (auto& [src, dsts] : adj_)
for (auto& dst : dsts) in_degree[dst]++;
// Walk topo order: stop each source layer, wait for its output channels
// to drain, then proceed to the next layer.
std::set<std::string> stopped;
for (auto& name : topo_) {
if (in_degree[name] == 0 || all_predecessors_stopped(name, stopped)) {
nodes_.at(name)->stop();
stopped.insert(name);
// Wait for output channels of this node to drain.
drain_output_channels(name);
}
}
// Stop any remaining nodes (sinks / nodes not yet stopped).
for (auto it = topo_.rbegin(); it != topo_.rend(); ++it)
if (!stopped.count(*it)) nodes_.at(*it)->stop();
}
bool running() const override { return watchdog_.joinable(); }
void set_name(std::string) override {}
@@ -160,8 +210,13 @@ public:
watchdog_interval_ = interval;
}
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_event_handler(EventHandler h) { event_handler_ = std::move(h); }
void register_pool(const std::string& name, IPoolProbe* probe) {
pool_probes_.emplace_back(name, probe);
}
#ifdef KPN_WEB_DEBUG
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
@@ -171,7 +226,7 @@ public:
// Can be called at any time; thread-safe (reads atomics with relaxed ordering).
void print_diagnostics(std::ostream& os = std::cerr) const {
auto s = collect_snapshots();
os << format_report(s.nodes, s.channels, s.elapsed_s);
os << format_report(s.nodes, s.channels, s.pools, s.elapsed_s);
}
private:
@@ -180,6 +235,7 @@ private:
struct Snapshots {
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
std::vector<PoolSnapshot> pools;
double elapsed_s;
};
@@ -195,11 +251,16 @@ private:
for (auto& probe : channel_probes_)
channels.push_back(probe->snapshot());
return {std::move(nodes), std::move(channels), elapsed_s};
std::vector<PoolSnapshot> pools;
for (auto& [name, probe] : pool_probes_)
pools.push_back(probe->snapshot(name));
return {std::move(nodes), std::move(channels), std::move(pools), elapsed_s};
}
static std::string format_report(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
const std::vector<PoolSnapshot>& pools = {},
double elapsed_s = 0.0) {
std::ostringstream os;
os << std::fixed << std::setprecision(1);
@@ -258,6 +319,31 @@ private:
<< flag << "\n";
}
// Pool table
if (!pools.empty()) {
os << "\n│ Thread Pools:\n";
os << "" << std::left
<< std::setw(16) << "name"
<< std::setw(10) << "threads"
<< std::setw(12) << "queued"
<< std::setw(12) << "active"
<< std::setw(14) << "in/s"
<< std::setw(14) << "out/s"
<< "\n" << std::string(78, '-') << "\n";
for (auto& p : pools) {
double in_rate = elapsed_s > 0.0 ? p.tasks_submitted / elapsed_s : 0.0;
double out_rate = elapsed_s > 0.0 ? p.tasks_completed / elapsed_s : 0.0;
os << "" << std::left
<< std::setw(16) << p.name
<< std::setw(10) << p.thread_count
<< std::setw(12) << p.queue_depth
<< std::setw(12) << p.active_count
<< std::setw(14) << in_rate
<< std::setw(14) << out_rate
<< "\n";
}
}
// Bottleneck hint: node with highest ema_exec_ms
if (!nodes.empty()) {
auto it = std::max_element(nodes.begin(), nodes.end(),
@@ -271,6 +357,31 @@ private:
return os.str();
}
// ── Shutdown helpers ──────────────────────────────────────────────────────
bool all_predecessors_stopped(const std::string& name,
const std::set<std::string>& stopped) const {
for (auto& [src, dsts] : adj_)
for (auto& dst : dsts)
if (dst == name && !stopped.count(src)) return false;
return true;
}
void drain_output_channels(const std::string& /*name*/) const {
// Poll all channel probes until none report non-zero fill.
// A short sleep prevents busy-spin; 1 ms is fine for drain purposes.
bool any_full = true;
while (any_full) {
any_full = false;
for (auto& probe : channel_probes_) {
auto snap = probe->snapshot();
if (snap.current_fill > 0) { any_full = true; break; }
}
if (any_full)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
// ── Cycle detection / topological sort ───────────────────────────────────
void dfs(const std::string& name, std::map<std::string, int>& color) {
@@ -292,16 +403,33 @@ private:
if (tok.stop_requested()) break;
auto s = collect_snapshots();
check_hung_nodes();
if (diag_handler_) {
diag_handler_(s.nodes, s.channels);
} else {
std::cerr << format_report(s.nodes, s.channels, s.elapsed_s);
std::cerr << format_report(s.nodes, s.channels, s.pools, s.elapsed_s);
}
}
});
}
void check_hung_nodes() const {
auto now_us = std::chrono::duration_cast<std::chrono::microseconds>(
clock_t::now().time_since_epoch()).count();
for (auto& [name, node] : nodes_) {
int64_t start = node->stats().exec_start_us.load(std::memory_order_relaxed);
if (start == 0) continue;
int64_t elapsed_ms = (now_us - start) / 1000;
// Warn if a node has been executing for > 5 s with no max_exec_time set,
// or if it exceeds its configured max. Threshold: 5000 ms default.
if (elapsed_ms > 5000) {
std::cerr << "[kpn] WARNING: node '" << name
<< "' has been executing for " << elapsed_ms << " ms\n";
}
}
}
void stop_watchdog() {
if (watchdog_.joinable())
watchdog_.request_stop(), watchdog_.join();
@@ -316,8 +444,10 @@ private:
std::map<std::string, std::string> exposed_outputs_;
std::set<std::pair<std::string, std::size_t>> connected_outputs_;
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
ErrorHandler error_handler_;
DiagnosticsHandler diag_handler_;
EventHandler event_handler_;
std::chrono::milliseconds watchdog_interval_{3000};
std::jthread watchdog_;
clock_t::time_point start_time_;
+36 -495
View File
@@ -1,41 +1,29 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "port.hpp"
#include "traits.hpp"
#include "inode.hpp"
#include "pool_node.hpp" // PoolNode, PoolObjectNode
#include <array>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <thread>
#include <tuple>
#include <type_traits>
// node.hpp — Node<> and ObjectNode<> as thin wrappers over PoolNode<>.
//
// Each Node owns a private single-thread ThreadPool so the API is unchanged:
// node.start() / node.stop() are self-contained with no external scheduler.
// Internally, all execution goes through PoolNode::fire_once() — the same code
// path as explicitly pool-scheduled nodes.
//
// To share a thread pool across multiple nodes, use make_pool_node() directly.
namespace kpn {
// ── INode — type-erased interface for Network / watchdog ─────────────────────
namespace detail {
struct INode {
virtual ~INode() = default;
virtual void start() = 0;
virtual void stop() = 0;
virtual bool running() const = 0;
virtual const NodeStats& stats() const = 0;
virtual NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const = 0;
virtual void set_name(std::string name) = 0;
// Private base initialized before PoolNode so its pool can be passed to the
// PoolNode constructor (C++ initialises bases left-to-right).
struct NodePrivatePool {
std::shared_ptr<ThreadPool> pool{std::make_shared<ThreadPool>(1)};
};
} // namespace detail
// ── Node ─────────────────────────────────────────────────────────────────────
//
// Template parameters:
// Func — the wrapped function (auto NTTP, deduced as a function pointer)
// InputNames — optional kpn::in<"a","b"> tag type (at most one)
// OutputNames — optional kpn::out<"x","y"> tag type (at most one)
template<auto Func,
typename InputTag = in<>,
@@ -44,259 +32,25 @@ template<auto Func,
std::size_t UniqueTag = 0>
class Node;
// Specialisation that unpacks the in<>/out<> tag packs
template<auto Func, fixed_string... InNames, fixed_string... OutNames,
fixed_string Label, std::size_t UniqueTag>
class Node<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
class Node<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>
: private detail::NodePrivatePool
, public PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
using Base = PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
public:
using F = decltype(Func);
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
// Identity accessors — used by StaticNetwork for diagnostics and type-level uniqueness
static constexpr std::string_view label() { return Label.view(); }
static constexpr std::size_t unique_tag = UniqueTag;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_node: number of input names must match function arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_node: number of output names must match return tuple size, or provide none"
);
explicit Node(std::size_t fifo_capacity = 5)
: fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
: detail::NodePrivatePool{}
, Base(pool, fifo_capacity)
{}
~Node() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
// Disable all input channels: drops queued items and unblocks waiting pop()
disable_inputs(std::make_index_sequence<input_count>{});
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
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";
}
}
}
// 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_;
void start() override { pool->start(); Base::start(); }
void stop() override { Base::stop(); pool->stop(); }
};
// ── ObjectNode — wraps a callable object (functor / class with operator()) ────
//
// Use this when the node needs state initialised in a constructor.
// The object must outlive the ObjectNode (stored by reference).
//
// Usage:
// MyFunctor obj(...);
// auto node = make_node(obj, in<"x">{}, out<"y">{}, capacity);
// ── ObjectNode ────────────────────────────────────────────────────────────────
template<typename Obj,
typename InputTag = in<>,
@@ -307,222 +61,20 @@ class ObjectNode;
template<typename Obj, fixed_string... InNames, fixed_string... OutNames,
fixed_string Label, std::size_t UniqueTag>
class ObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
class ObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>
: private detail::NodePrivatePool
, public PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
using Base = PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
public:
using F = decltype(&Obj::operator());
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static constexpr std::string_view label() { return Label.view(); }
static constexpr std::size_t unique_tag = UniqueTag;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_node: number of input names must match operator() arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_node: number of output names must match return tuple size, or provide none"
);
explicit ObjectNode(Obj& obj, std::size_t fifo_capacity = 5)
: obj_(obj), fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
: detail::NodePrivatePool{}
, Base(obj, pool, fifo_capacity)
{}
~ObjectNode() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
disable_inputs(std::make_index_sequence<input_count>{});
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
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";
}
}
}
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_;
void start() override { pool->start(); Base::start(); }
void stop() override { Base::stop(); pool->stop(); }
};
// ── make_node overloads for callable objects ──────────────────────────────────
@@ -548,35 +100,24 @@ auto make_node(Obj& obj, in<InNames...>, out<OutNames...>, std::size_t fifo_capa
}
// ── make_node factory (NTTP) ──────────────────────────────────────────────────
//
// Usage:
// make_node<func>(capacity)
// make_node<func, "label">(capacity)
// make_node<func, "label", 1>(capacity) // UniqueTag=1
// make_node<func>(in<"a","b">{}, capacity)
// make_node<func, "label">(in<"a","b">{}, out<"x">{}, capacity)
// No port names
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
auto make_node(std::size_t fifo_capacity = 5) {
return Node<Func, in<>, out<>, Label, UniqueTag>(fifo_capacity);
}
// in<> only
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... InNames>
auto make_node(in<InNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<InNames...>, out<>, Label, UniqueTag>(fifo_capacity);
}
// out<> only
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... OutNames>
auto make_node(out<OutNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<>, out<OutNames...>, Label, UniqueTag>(fifo_capacity);
}
// in<> and out<>
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... InNames, fixed_string... OutNames>
auto make_node(in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {
+743
View File
@@ -0,0 +1,743 @@
#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; }
void set_overflow_callback(NodeEventCallback cb) { event_callbacks_[0] = std::move(cb); }
void set_network_overflow_callback(NodeEventCallback cb) override { event_callbacks_[1] = std::move(cb); }
void set_closed_callback(NodeEventCallback cb) { closed_callbacks_[0] = std::move(cb); }
void set_network_closed_callback(NodeEventCallback cb) override { closed_callbacks_[1] = std::move(cb); }
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 disable_outputs(std::index_sequence<Is...>) {
auto disable_one = [](auto* ch) { if (ch) ch->disable(); };
(disable_one(std::get<Is>(output_channels_)), ...);
}
template<std::size_t... Is>
void register_callbacks(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->set_push_callback(
[this] { on_input_ready(); }), ...);
}
static void fire_callbacks(const std::array<NodeEventCallback, 2>& cbs) {
const auto ts = std::chrono::steady_clock::now();
for (auto& cb : cbs) if (cb) cb(ts);
}
void self_stop() {
disable_inputs(std::make_index_sequence<input_count>{});
disable_outputs(std::make_index_sequence<output_count>{});
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);
}
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&) {
fire_callbacks(closed_callbacks_);
self_stop();
return;
} catch (const ChannelOverflowError&) {
fire_callbacks(event_callbacks_);
} catch (...) {
if (error_handler_ && error_handler_(name_, std::current_exception())) {
// continue — fall through to resubmit check
} else {
fire_callbacks(closed_callbacks_);
self_stop();
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};
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
std::array<NodeEventCallback, 2> closed_callbacks_{};
};
// ── 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; }
void set_overflow_callback(NodeEventCallback cb) { event_callbacks_[0] = std::move(cb); }
void set_network_overflow_callback(NodeEventCallback cb) override { event_callbacks_[1] = std::move(cb); }
void set_closed_callback(NodeEventCallback cb) { closed_callbacks_[0] = std::move(cb); }
void set_network_closed_callback(NodeEventCallback cb) override { closed_callbacks_[1] = std::move(cb); }
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 disable_outputs(std::index_sequence<Is...>) {
auto disable_one = [](auto* ch) { if (ch) ch->disable(); };
(disable_one(std::get<Is>(output_channels_)), ...);
}
template<std::size_t... Is>
void register_callbacks(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->set_push_callback([this] { on_input_ready(); }), ...);
}
static void fire_callbacks(const std::array<NodeEventCallback, 2>& cbs) {
const auto ts = std::chrono::steady_clock::now();
for (auto& cb : cbs) if (cb) cb(ts);
}
void self_stop() {
disable_inputs(std::make_index_sequence<input_count>{});
disable_outputs(std::make_index_sequence<output_count>{});
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);
}
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&) {
fire_callbacks(closed_callbacks_);
self_stop();
return;
} catch (const ChannelOverflowError&) {
fire_callbacks(event_callbacks_);
} catch (...) {
if (error_handler_ && error_handler_(name_, std::current_exception())) {
} else {
fire_callbacks(closed_callbacks_);
self_stop();
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};
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
std::array<NodeEventCallback, 2> closed_callbacks_{};
};
// ── 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
View 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
+98 -44
View File
@@ -26,6 +26,9 @@ namespace nb = nanobind;
// them via IVariantChannel adapters. The variant only lives at the boundary;
// each node's internal Channel<T> stores raw T values.
template<typename Variant>
class PyNode; // forward declaration
template<typename Variant>
class PyNetwork {
public:
@@ -43,8 +46,6 @@ public:
}
// connect(src_name, out_idx, dst_name, in_idx)
// Wires src's output port out_idx to dst's input port in_idx.
// Type check: both sides must carry the same T.
void connect(const std::string& src_name, std::size_t out_idx,
const std::string& dst_name, std::size_t in_idx)
{
@@ -65,7 +66,6 @@ public:
dst_name + ".input[" + std::to_string(in_idx) +
"] (" + dst.input_type(in_idx).name() + ")");
// The destination node owns the input channel — get it, then tell src to use it.
auto ch = dst.input_channel(in_idx);
src.set_output_channel(out_idx, std::move(ch));
adj_[src_name].push_back(dst_name);
@@ -92,16 +92,12 @@ public:
// ── Python tap/inject ─────────────────────────────────────────────────────
// Read one value from node's output port. Releases GIL while blocking.
nb::object read(const std::string& node_name, std::size_t out_idx) {
// We need a channel that sits on the output of this node.
// read() installs a tap channel if not already present.
auto key = tap_key(node_name, out_idx);
if (!taps_.count(key)) {
auto& src = node_at(node_name);
if (out_idx >= src.output_count())
throw std::out_of_range(node_name + ": output index out of range");
// Create a tap channel matching the output type and wire it
auto tap = make_tap_channel(src.output_type(out_idx));
src.set_output_channel(out_idx, tap);
taps_[key] = std::move(tap);
@@ -114,7 +110,6 @@ public:
return variant_to_python(std::move(v));
}
// Write a Python value into node's input port. Releases GIL while blocking.
void write(const std::string& node_name, std::size_t in_idx, nb::object value) {
auto& dst = node_at(node_name);
if (in_idx >= dst.input_count())
@@ -128,8 +123,31 @@ public:
}
}
// ── Converter registration ────────────────────────────────────────────────
// Called once per type at module init time to register to/from Python converters.
// ── Python-callable node creation ─────────────────────────────────────────
// Creates a PyNode wrapping a Python callable and adds it to the graph.
// Type names must have been registered via register_full_type<T>().
void add_node_python(std::string name, nb::object callable,
std::vector<std::string> in_names,
std::vector<std::string> out_names,
std::size_t capacity = 5)
{
std::vector<std::type_index> in_types, out_types;
for (auto& s : in_names) in_types.push_back(resolve_type_name(s));
for (auto& s : out_names) out_types.push_back(resolve_type_name(s));
add(std::move(name),
std::make_shared<PyNode<Variant>>(
std::move(callable),
std::move(in_types),
std::move(out_types),
to_python_,
from_python_,
ch_factories_,
capacity));
}
// ── Type converter registration ───────────────────────────────────────────
template<typename T>
void register_type(
@@ -143,6 +161,52 @@ public:
};
}
// register_channel_factory<T>: registers factory for creating input channels.
template<typename T>
void register_channel_factory() {
ch_factories_[std::type_index(typeid(T))] =
[](std::size_t cap) -> std::shared_ptr<VChannel> {
return std::make_shared<VariantChannel<T, Variant>>(
std::make_shared<Channel<T>>(cap));
};
}
// Backward-compatible alias.
template<typename T>
void register_tap_factory(std::size_t = 5) {
register_channel_factory<T>();
}
// register_full_type<T>: registers converters + channel factory + type name.
// This is what auto_bind.hpp calls; manual bindings can call register_type +
// register_tap_factory separately for backward compatibility.
template<typename T>
void register_full_type(
std::function<nb::object(const T&)> to_py,
std::function<T(nb::object)> from_py,
const char* friendly_name = nullptr)
{
register_type<T>(std::move(to_py), std::move(from_py));
register_channel_factory<T>();
auto idx = std::type_index(typeid(T));
type_names_.insert_or_assign(typeid(T).name(), idx);
if (friendly_name) type_names_.insert_or_assign(friendly_name, idx);
}
// ── Type name lookup ──────────────────────────────────────────────────────
void register_type_name(const std::string& name, std::type_index idx) {
type_names_.insert_or_assign(name, idx);
}
std::type_index resolve_type_name(const std::string& name) const {
auto it = type_names_.find(name);
if (it == type_names_.end())
throw std::runtime_error(
"type '" + name + "' not registered — call register_full_type<T>() first");
return it->second;
}
private:
VNode& node_at(const std::string& name) {
auto it = nodes_.find(name);
@@ -166,15 +230,14 @@ private:
return node + ":" + std::to_string(idx);
}
std::shared_ptr<VChannel> make_tap_channel(std::type_index type) {
// Create the right VariantChannel<T> based on the registered type index.
// We need a factory registered per type — stored in tap_factories_.
auto it = tap_factories_.find(type);
if (it == tap_factories_.end())
std::shared_ptr<VChannel> make_tap_channel(std::type_index type,
std::size_t cap = 5) {
auto it = ch_factories_.find(type);
if (it == ch_factories_.end())
throw std::runtime_error(
"no tap factory for type: " + std::string(type.name()) +
"was register_type() called for this type?");
return it->second();
"no channel factory for type: " + std::string(type.name()) +
"call register_full_type<T>() or register_tap_factory<T>()");
return it->second(cap);
}
nb::object variant_to_python(Variant v) {
@@ -194,18 +257,6 @@ private:
return it->second(std::move(obj));
}
public:
// Called by register_type to also register a tap channel factory.
template<typename T>
void register_tap_factory(std::size_t capacity = 5) {
auto idx = std::type_index(typeid(T));
tap_factories_[idx] = [capacity]() -> std::shared_ptr<VChannel> {
auto ch = std::make_shared<Channel<T>>(capacity);
return std::make_shared<VariantChannel<T, Variant>>(std::move(ch));
};
}
private:
std::map<std::string, std::shared_ptr<VNode>> nodes_;
std::map<std::string, std::vector<std::string>> adj_;
std::vector<std::string> topo_;
@@ -213,19 +264,27 @@ private:
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
std::map<std::type_index, std::function<std::shared_ptr<VChannel>()>> tap_factories_;
// Channel factory: type → function(capacity) → VChannel.
// Used both for tap channels (read()) and PyNode input channel creation.
std::map<std::type_index,
std::function<std::shared_ptr<VChannel>(std::size_t)>> ch_factories_;
// Friendly name → type_index (e.g. "int" → typeid(int)).
std::map<std::string, std::type_index> type_names_;
};
// ── PyNode<Variant> ───────────────────────────────────────────────────────────
// A pure-Python processing node. Holds a nanobind callable.
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs (release GIL).
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs.
template<typename Variant>
class PyNode : public IVariantNode<Variant> {
public:
using VChannel = IVariantChannel<Variant>;
using ChannelFactory = std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
using ChannelFactory =
std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
PyNode(nb::object callable,
std::vector<std::type_index> in_types,
@@ -264,7 +323,6 @@ public:
for (auto& ch : in_channels_) ch->disable();
if (thread_.joinable()) {
thread_.request_stop();
// Release GIL while joining — run_loop may be waiting to acquire it.
nb::gil_scoped_release release;
thread_.join();
}
@@ -311,20 +369,17 @@ public:
private:
void run_loop() {
// This thread does not hold the GIL. It acquires it only for Python calls.
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
// Pop all inputs — no GIL needed, these are pure C++ channel ops
std::vector<Variant> inputs(in_channels_.size());
for (std::size_t i = 0; i < in_channels_.size(); ++i)
inputs[i] = in_channels_[i]->pop();
auto t1 = clock_t::now();
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
// Acquire GIL only for the Python call and type conversion
std::vector<Variant> outputs;
{
nb::gil_scoped_acquire acquire;
@@ -344,10 +399,9 @@ private:
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
// Push outputs — no GIL needed
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
if (out_channels_[i])
out_channels_[i]->push(std::move(outputs[i]));
@@ -385,9 +439,9 @@ private:
NodeStats stats_;
};
// ── register_py_network ───────────────────────────────────────────────────────
// Registers PyNetwork<Variant> and PyNode<Variant> with the given nanobind module.
// Call once per module, passing the Variant type derived from your registered node types.
// ── register_py_network (legacy helper) ───────────────────────────────────────
// Registers PyNetwork<Variant> with the given nanobind module.
// Prefer bind_network<Registry> from auto_bind.hpp for new code.
template<typename Variant>
void register_py_network(nb::module_& m, const char* class_name = "Network") {
@@ -402,7 +456,7 @@ void register_py_network(nb::module_& m, const char* class_name = "Network") {
.def("start", &Net::start)
.def("stop", &Net::stop)
.def("read", &Net::read,
nb::arg("node"), nb::arg("out_idx") = 0)
nb::arg("node"), nb::arg("out_idx") = std::size_t(0))
.def("write", &Net::write,
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
}
+211
View File
@@ -0,0 +1,211 @@
#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);
}
// Lock cv_mx_ before notifying so the stop signal can't be lost in the
// gap between a worker's predicate check and its wait() (see submit()).
{ std::lock_guard<std::mutex> lk(cv_mx_); }
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);
// Synchronize with worker_loop's predicate evaluation: taking cv_mx_
// here guarantees a worker is either before its predicate check (and
// will observe total_ > 0) or already blocked in wait() (and will be
// woken). Without this, notify_one() can slip into the gap between the
// worker's predicate check and its wait(), and be lost — a deadlock.
{ std::lock_guard<std::mutex> lk(cv_mx_); }
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.
// Lock drain_mx_ before notifying to avoid a lost wakeup against
// drain()'s predicate check (same hazard as submit()/cv_mx_).
if (total_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
{ std::lock_guard<std::mutex> lk(drain_mx_); }
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
+190
View File
@@ -0,0 +1,190 @@
#pragma once
#include "diagnostics.hpp"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <mutex>
#include <utility>
#include <vector>
namespace kpn {
template<typename T> class Channel; // forward declaration for acquire_balanced
// ── SharedResource ────────────────────────────────────────────────────────────
//
// Wraps an exclusive resource (e.g. an ONNX session, a CUDA stream) and
// arbitrates concurrent access using a priority-based waiter queue.
//
// When multiple nodes compete, the one with the highest priority score wins
// the next slot. Priority is re-evaluated at release time so it reflects the
// current queue state, not the state when the node first started waiting.
//
// Starvation prevention: each waiter's effective score grows with elapsed wait
// time (aging_per_second), ensuring a low-priority node eventually gets served.
//
// Usage:
// SharedResource<OrtSession> res(session_args...);
//
// // inside a node functor —
// auto guard = res.acquire_balanced(in_channel, out_channel);
// guard->Run(...); // guard releases automatically on scope exit
template<typename T>
class SharedResource : public IResourceProbe {
public:
// ── RAII guard ────────────────────────────────────────────────────────────
class Guard {
SharedResource* owner_;
explicit Guard(SharedResource* o) : owner_(o) {}
friend class SharedResource;
public:
Guard(Guard&& o) noexcept : owner_(std::exchange(o.owner_, nullptr)) {}
Guard& operator=(Guard&&) = delete;
Guard(const Guard&) = delete;
Guard& operator=(const Guard&) = delete;
~Guard() { if (owner_) owner_->release(); }
T& get() { return owner_->resource_; }
T* operator->() { return &owner_->resource_; }
T& operator*() { return owner_->resource_; }
};
// ── Construction ──────────────────────────────────────────────────────────
template<typename... Args>
explicit SharedResource(Args&&... args)
: resource_(std::forward<Args>(args)...) {}
SharedResource(const SharedResource&) = delete;
SharedResource& operator=(const SharedResource&) = delete;
SharedResource(SharedResource&&) = delete;
SharedResource& operator=(SharedResource&&) = delete;
// ── Acquire ───────────────────────────────────────────────────────────────
// Acquire with a callable that returns a priority in [0, 1].
// Higher = more urgent. Called at every release to pick the best waiter.
template<typename PriorityFn>
Guard acquire(PriorityFn&& fn) {
std::unique_lock lock(mutex_);
if (!held_) {
held_ = true;
acq_.fetch_add(1, std::memory_order_relaxed);
return Guard(this);
}
Waiter w{std::function<float()>(std::forward<PriorityFn>(fn)), clock_t::now()};
waiters_.push_back(&w);
update_peak(waiters_.size());
current_waiters_.store(waiters_.size(), std::memory_order_relaxed);
auto t0 = w.wait_start;
w.cv.wait(lock, [&w] { return w.ready; });
int64_t wait_us = std::chrono::duration_cast<std::chrono::microseconds>(
clock_t::now() - t0).count();
waiters_.erase(std::find(waiters_.begin(), waiters_.end(), &w));
current_waiters_.store(waiters_.size(), std::memory_order_relaxed);
acq_.fetch_add(1, std::memory_order_relaxed);
total_wait_us_.fetch_add(static_cast<uint64_t>(wait_us > 0 ? wait_us : 0),
std::memory_order_relaxed);
return Guard(this);
}
// Acquire with no priority (all waiters treated equally, order is fair-ish).
Guard acquire() {
return acquire([] { return 0.5f; });
}
// Acquire with priority derived from channel fill fractions:
// score = input_fill × output_headroom
// A node with a full input queue and empty output queue has the highest
// urgency — it has work to do and nowhere to stall downstream.
template<typename In, typename Out>
Guard acquire_balanced(const Channel<In>& in_ch, const Channel<Out>& out_ch) {
return acquire([&in_ch, &out_ch] {
float in_fill = in_ch.capacity() ? float(in_ch.size()) / in_ch.capacity() : 0.5f;
float out_head = out_ch.capacity() ? 1.0f - float(out_ch.size()) / out_ch.capacity() : 0.5f;
return in_fill * out_head;
});
}
// ── IResourceProbe ────────────────────────────────────────────────────────
ResourceSnapshot snapshot(const std::string& name) const override {
std::lock_guard lock(mutex_);
uint64_t a = acq_.load(std::memory_order_relaxed);
uint64_t w = total_wait_us_.load(std::memory_order_relaxed);
return {
name,
a,
a > 0 ? double(w) / a / 1000.0 : 0.0,
peak_waiters_.load(std::memory_order_relaxed),
current_waiters_.load(std::memory_order_relaxed),
held_,
};
}
private:
void release() {
std::unique_lock lock(mutex_);
if (waiters_.empty()) {
held_ = false;
return;
}
// Re-evaluate every waiter's current priority and apply aging bonus.
auto now = clock_t::now();
Waiter* best = nullptr;
float best_score = -1.0f;
for (Waiter* w : waiters_) {
float age_s = std::chrono::duration<float>(now - w->wait_start).count();
float score = w->priority_fn() + age_s * kAgingPerSecond;
if (score > best_score) { best_score = score; best = w; }
}
best->ready = true;
best->cv.notify_one();
// held_ stays true — ownership transfers to the woken waiter.
}
void update_peak(std::size_t n) {
uint64_t prev = peak_waiters_.load(std::memory_order_relaxed);
while (n > prev &&
!peak_waiters_.compare_exchange_weak(prev, n,
std::memory_order_relaxed, std::memory_order_relaxed))
;
}
struct Waiter {
std::function<float()> priority_fn;
clock_t::time_point wait_start;
std::condition_variable cv;
bool ready{false};
Waiter(std::function<float()> fn, clock_t::time_point t)
: priority_fn(std::move(fn)), wait_start(t) {}
};
static constexpr float kAgingPerSecond = 0.05f;
T resource_;
bool held_{false};
mutable std::mutex mutex_;
std::vector<Waiter*> waiters_;
std::atomic<uint64_t> acq_{0};
std::atomic<uint64_t> total_wait_us_{0};
std::atomic<uint64_t> peak_waiters_{0};
std::atomic<uint64_t> current_waiters_{0};
};
// ── Factory ───────────────────────────────────────────────────────────────────
template<typename T, typename... Args>
SharedResource<T> make_shared_resource(Args&&... args) {
return SharedResource<T>(std::forward<Args>(args)...);
}
} // namespace kpn
+111 -17
View File
@@ -2,7 +2,7 @@
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fanout.hpp"
#include "node.hpp"
#include "inode.hpp"
#include "port.hpp"
#include "tmp/fanout_groups.hpp"
#include "tmp/topo_sort.hpp"
@@ -13,7 +13,9 @@
#endif
#include <iostream>
#include <map>
#include <string>
#include <thread>
#include <tuple>
#include <type_traits>
#include <utility>
@@ -74,6 +76,10 @@ std::string node_display_name() {
return std::string(lbl);
} else if constexpr (requires { NodeT::is_fanout_node; NodeT::unique_tag; }) {
return "fanout[" + std::to_string(NodeT::unique_tag) + "]";
} else if constexpr (requires { NodeT::is_router_node; NodeT::unique_tag; }) {
return "router[" + std::to_string(NodeT::unique_tag) + "]";
} else if constexpr (requires { NodeT::is_filter_node; NodeT::unique_tag; }) {
return "filter[" + std::to_string(NodeT::unique_tag) + "]";
} else if constexpr (requires { NodeT::unique_tag; }) {
return "node[" + std::to_string(NodeT::unique_tag) + "]";
} else {
@@ -106,21 +112,35 @@ public:
void start() override {
stop_flag_ = false;
start_time_ = clock_t::now();
if (event_handler_) {
for (std::size_t i = 0; i < user_nodes_topo_.size(); ++i) {
auto* node = user_nodes_topo_[i];
const auto& n = user_node_names_[i];
node->set_network_overflow_callback(
[this, n](auto ts) { event_handler_(n, NodeEvent::Overflow, ts); });
node->set_network_closed_callback(
[this, n](auto ts) { event_handler_(n, NodeEvent::Closed, ts); });
}
}
for (auto* n : user_nodes_topo_) n->start();
for (auto* n : fanout_nodes_ptr_) n->start();
#ifdef KPN_WEB_DEBUG
web_server_ = std::make_unique<web_debug::WebDebugServer>(
web_debug_port_,
[this]() {
auto s = collect_snapshots();
return web_debug::to_json(s.nodes, s.channels, s.elapsed_s);
});
web_server_->start();
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
if (web_server_enabled_) {
web_server_ = std::make_unique<web_debug::WebDebugServer>(
web_debug_port_,
[this]() {
auto s = collect_snapshots();
return web_debug::to_json(s.nodes, s.channels, s.resources, s.elapsed_s, s.pools);
});
web_server_->start();
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
}
#endif
}
void stop() override {
void stop() override { halt(); }
void halt() override {
stop_flag_ = true;
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
@@ -131,6 +151,22 @@ public:
(*it)->stop();
}
// shutdown(): graceful drain in topological order (sources first).
// Stops source nodes, polls channels until empty, then stops each downstream layer.
void shutdown() override {
stop_flag_ = true;
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
#endif
// user_nodes_topo_ is already in sources-first order.
// Stop each node and drain its output channels before moving on.
for (auto* n : user_nodes_topo_) {
n->stop();
drain_all_channels();
}
for (auto* n : fanout_nodes_ptr_) n->stop();
}
bool running() const override { return !stop_flag_; }
void set_name(std::string name) override { name_ = std::move(name); }
@@ -139,10 +175,36 @@ public:
return {n, 0, 0, 0, 0, 0, 0, 0};
}
using EventHandler =
std::function<void(std::string_view node_name, NodeEvent,
std::chrono::steady_clock::time_point)>;
void set_event_handler(EventHandler h) { event_handler_ = std::move(h); }
#ifdef KPN_WEB_DEBUG
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
// Called by DebugHub::register_network() so the hub owns the debug server.
void disable_web_server() { web_server_enabled_ = false; }
#endif
// Returns a snapshot of this network's nodes and channels for the DebugHub.
NetworkSnapshot network_snapshot() const {
auto s = collect_snapshots();
return {"", std::move(s.nodes), std::move(s.channels), s.elapsed_s};
}
// Register a shared resource so it appears in diagnostics and the debug UI.
// The probe must outlive this network (typically the resource is on the same stack).
void register_resource(const std::string& name, IResourceProbe* 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
void print_diagnostics(std::ostream& os = std::cerr) const {
os << "\n┌─ KPN++ StaticNetwork diagnostics ─────────────────────────────\n";
@@ -159,9 +221,11 @@ public:
private:
struct Snapshots {
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
double elapsed_s;
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
std::vector<ResourceSnapshot> resources;
std::vector<PoolSnapshot> pools;
double elapsed_s;
};
Snapshots collect_snapshots() const {
@@ -178,7 +242,27 @@ private:
for (auto& probe : channel_probes_)
channels.push_back(probe->snapshot());
return {std::move(nodes), std::move(channels), elapsed_s};
std::vector<ResourceSnapshot> resources;
for (auto& [name, probe] : resource_probes_)
resources.push_back(probe->snapshot(name));
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_;
@@ -189,9 +273,13 @@ private:
std::vector<std::string> user_node_names_;
std::vector<std::string> fanout_node_names_;
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
std::vector<std::pair<std::string, IResourceProbe*>> resource_probes_;
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
EventHandler event_handler_;
clock_t::time_point start_time_;
#ifdef KPN_WEB_DEBUG
uint16_t web_debug_port_{9090};
bool web_server_enabled_{true};
std::unique_ptr<web_debug::WebDebugServer> web_server_;
#endif
};
@@ -229,12 +317,16 @@ auto make_network(Edges&&... edges) {
auto* s = static_cast<INode*>(&e.src);
auto* d = static_cast<INode*>(&e.dst);
if (std::find(user_node_ptrs.begin(), user_node_ptrs.end(), s) == user_node_ptrs.end()) {
auto sname = node_display_name<SrcT>();
user_node_ptrs.push_back(s);
user_node_names.push_back(node_display_name<SrcT>());
user_node_names.push_back(sname);
s->set_name(sname);
}
if (std::find(user_node_ptrs.begin(), user_node_ptrs.end(), d) == user_node_ptrs.end()) {
auto dname = node_display_name<DstT>();
user_node_ptrs.push_back(d);
user_node_names.push_back(node_display_name<DstT>());
user_node_names.push_back(dname);
d->set_name(dname);
}
};
(collect(edges), ...);
@@ -317,8 +409,10 @@ auto make_network(Edges&&... edges) {
std::apply([&](auto&... fn) {
([&](auto& node) {
using NodeT = std::decay_t<decltype(node)>;
auto fname = node_name.template operator()<NodeT>();
static_cast<INode&>(node).set_name(fname);
fanout_ptrs.push_back(static_cast<INode*>(&node));
fanout_node_names.push_back(node_name.template operator()<NodeT>());
fanout_node_names.push_back(fname);
}(fn), ...);
}, *fanout_storage);
+14
View File
@@ -83,4 +83,18 @@ template<typename F>
inline constexpr std::size_t output_count_v =
std::tuple_size_v<normalised_return_t<return_t<F>>>;
// ── repeat_tuple: std::tuple<T, T, ..., T> with N repetitions ────────────────
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
struct repeat_tuple;
template<typename T, std::size_t N, std::size_t... Is>
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
template<std::size_t> using always_T = T;
using type = std::tuple<always_T<Is>...>;
};
template<typename T, std::size_t N>
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
} // namespace kpn
+6 -4
View File
@@ -239,11 +239,13 @@ private:
};
// ── PythonConverter ───────────────────────────────────────────────────────────
// Specialise for each type you want to use across a PyNetwork.
// Required members: to_python(const T&), from_python(nb::object).
// Optional member: static constexpr const char* type_name = "friendly_name";
// Built-in specialisations for int/float/double/bool/std::string are provided
// automatically when you include <kpn/python/auto_bind.hpp>.
template<typename T>
struct PythonConverter {
static_assert(sizeof(T) == 0,
"PythonConverter<T> must be specialised for each type used in a PyNetwork");
};
struct PythonConverter {};
} // namespace kpn
+80 -9
View File
@@ -46,7 +46,9 @@ static std::pair<std::string,std::string> parse_edge_name(const std::string& nam
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
double elapsed_s = 0.0) {
const std::vector<ResourceSnapshot>& resources = {},
double elapsed_s = 0.0,
const std::vector<PoolSnapshot>& pools = {}) {
std::ostringstream o;
o << std::fixed;
o.precision(2);
@@ -84,6 +86,32 @@ static std::string to_json(const std::vector<NodeSnapshot>& nodes,
<< ",\"bw_mbs\":" << c.bandwidth_mbs(elapsed_s)
<< "}";
}
o << "],\"resources\":[";
for (std::size_t i = 0; i < resources.size(); ++i) {
const auto& r = resources[i];
if (i) o << ',';
o << "{\"name\":\"" << escape_json(r.name) << "\""
<< ",\"acquisitions\":" << r.acquisitions
<< ",\"avg_wait_ms\":" << r.avg_wait_ms
<< ",\"peak_waiters\":" << r.peak_waiters
<< ",\"current_waiters\":" << r.current_waiters
<< ",\"held\":" << (r.held ? "true" : "false")
<< "}";
}
o << "],\"pools\":[";
for (std::size_t i = 0; i < pools.size(); ++i) {
const auto& p = pools[i];
if (i) o << ',';
double in_rate = elapsed_s > 0.0 ? p.tasks_submitted / elapsed_s : 0.0;
double out_rate = elapsed_s > 0.0 ? p.tasks_completed / elapsed_s : 0.0;
o << "{\"name\":\"" << escape_json(p.name) << "\""
<< ",\"thread_count\":" << p.thread_count
<< ",\"queue_depth\":" << p.queue_depth
<< ",\"active_count\":" << p.active_count
<< ",\"in_rate\":" << in_rate
<< ",\"out_rate\":" << out_rate
<< "}";
}
o << "]}";
return o.str();
}
@@ -112,12 +140,24 @@ static const char* HTML = R"html(<!DOCTYPE html>
border-radius: 4px; padding: 8px 12px; font-size: 11px; pointer-events: none;
display: none; white-space: pre; line-height: 1.6;
}
#resources {
position: absolute; bottom: 12px; right: 12px;
background: #16213e; border: 1px solid #0f3460; border-radius: 4px;
padding: 8px 12px; font-size: 10px; min-width: 220px;
display: none;
}
#resources h2 { margin: 0 0 6px; font-size: 11px; color: #e94560; }
.res-row { display: flex; justify-content: space-between; gap: 12px; margin-top: 3px; }
.res-name { color: #eee; }
.res-held-y { color: #e94560; }
.res-held-n { color: #4CAF50; }
</style>
</head>
<body>
<div id="header"><h1>KPN++ Web Debug</h1><span id="status">connecting...</span></div>
<svg id="graph"></svg>
<div id="tooltip"></div>
<div id="resources"><h2>Shared Resources</h2><div id="res-list"></div></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const nodeRadius = 30;
@@ -139,7 +179,7 @@ const defs = svg.append('defs');
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
let sim, linkSel, nodeSel, labelSel, edgeLabelSel;
let sim, linkSel, nodeSel, labelSel, edgeLabelSel, edgeLabelBwSel;
let nodes = [], links = [];
function edgeColor(fill_pct) {
@@ -186,6 +226,10 @@ function init(data) {
.attr('class', 'link-label')
.text(d => `${d.fill_pct.toFixed(0)}%`);
edgeLabelBwSel = g.append('g').selectAll('text').data(links).join('text')
.attr('class', 'link-label')
.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
// Nodes
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
.attr('class', 'node')
@@ -200,6 +244,7 @@ function init(data) {
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
nodeSel = nodeG;
renderResources(data.resources);
// Tooltips
const tip = d3.select('#tooltip');
@@ -224,7 +269,25 @@ function init(data) {
}).on('mouseleave', () => tip.style('display','none'));
}
function renderResources(resources) {
const panel = document.getElementById('resources');
const list = document.getElementById('res-list');
if (!resources || resources.length === 0) { panel.style.display = 'none'; return; }
panel.style.display = 'block';
list.innerHTML = resources.map(r => {
const heldCls = r.held ? 'res-held-y' : 'res-held-n';
const heldTxt = r.held ? 'HELD' : 'free';
return `<div class="res-row">
<span class="res-name">${r.name}</span>
<span class="${heldCls}">${heldTxt}</span>
<span>wait ${r.avg_wait_ms.toFixed(1)}ms</span>
<span>waiters ${r.current_waiters}/${r.peak_waiters}</span>
</div>`;
}).join('');
}
function update(data) {
renderResources(data.resources);
// Update node stats in-place (preserve simulation x/y positions)
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
nodes.forEach(n => {
@@ -252,6 +315,7 @@ function update(data) {
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
edgeLabelBwSel.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
}
function ticked() {
@@ -278,6 +342,10 @@ function ticked() {
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 - 6);
edgeLabelBwSel
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 + 8);
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
@@ -311,12 +379,14 @@ class WebDebugServer {
public:
using SnapshotFn = std::function<std::string()>;
explicit WebDebugServer(uint16_t port, SnapshotFn fn)
: port_(port), snapshot_fn_(std::move(fn)) {}
explicit WebDebugServer(uint16_t port, SnapshotFn fn,
const char* custom_html = nullptr)
: port_(port), snapshot_fn_(std::move(fn))
, html_(custom_html ? custom_html : HTML) {}
void start() {
svr_.Get("/", [](const httplib::Request&, httplib::Response& res) {
res.set_content(HTML, "text/html");
svr_.Get("/", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(html_, "text/html");
});
svr_.Get("/api/snapshot", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(snapshot_fn_(), "application/json");
@@ -337,10 +407,11 @@ public:
WebDebugServer& operator=(const WebDebugServer&) = delete;
private:
uint16_t port_;
SnapshotFn snapshot_fn_;
uint16_t port_;
SnapshotFn snapshot_fn_;
const char* html_;
httplib::Server svr_;
std::thread thread_;
std::thread thread_;
};
} // namespace kpn::web_debug
+50
View File
@@ -0,0 +1,50 @@
site_name: KPN++
site_description: A C++20 Kahn Process Network library
repo_url: https://github.com/yourusername/kpn
repo_name: kpn
theme:
name: material
palette:
- scheme: slate
primary: indigo
accent: indigo
features:
- navigation.tabs
- navigation.sections
- navigation.top
- content.code.copy
- content.code.annotate
nav:
- Home: index.md
- Getting Started: getting-started.md
- Concepts:
- Nodes: nodes.md
- Networks: network.md
- Channels: channels.md
- Error Handling & Events: error-handling.md
- Advanced:
- Static Networks: static-network.md
- Shared Resources: shared-resource.md
- Fan-out & Routing: fanout.md
- Examples: examples.md
markdown_extensions:
- admonition
- toc:
permalink: true
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- pymdownx.snippets:
base_path: ['.']
check_paths: true
- pymdownx.details
- attr_list
- md_in_html
+18 -143
View File
@@ -1,164 +1,39 @@
#define KPN_BUILD_PYTHON
#include <kpn/python/bindings.hpp>
#include <nanobind/nanobind.h>
#include <nanobind/stl/shared_ptr.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
#include <kpn/python/auto_bind.hpp>
#include <iostream>
#include <map>
namespace nb = nanobind;
using namespace kpn;
using namespace kpn::python;
// ── Node functions for the hello-pipeline examples ────────────────────────────
// ── Demo node functions (hello-pipeline) ──────────────────────────────────────
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
// ── Variant type ──────────────────────────────────────────────────────────────
// Deduplicated port types across all registered node functions.
// produce: () → int | double_it: int → int | print_it: int → void
// Unique types: int
// ── Registry ──────────────────────────────────────────────────────────────────
// Variant type is auto-deduced as std::variant<int> from the port types above.
using KpnVariant = std::variant<int>;
using Net = PyNetwork<KpnVariant>;
using ProduceNode = VariantNodeWrapper<produce, KpnVariant>;
using DoubleItNode= VariantNodeWrapper<double_it, KpnVariant>;
using PrintItNode = VariantNodeWrapper<print_it, KpnVariant>;
using DemoNodes = NodeRegistry<
Entry<produce, "produce">,
Entry<double_it, "double_it">,
Entry<print_it, "print_it">
>;
// ── Converter helpers for int ─────────────────────────────────────────────────
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>();
}
// ── Module ────────────────────────────────────────────────────────────────────
NB_MODULE(kpn_python, m) {
m.doc() = "KPN++ Python bindings — Kahn Process Network library";
// ── IVariantNode base ─────────────────────────────────────────────────────
nb::class_<IVariantNode<KpnVariant>>(m, "INode");
// Registers: Network, INode, ProduceNode, DoubleItNode, PrintItNode,
// make_produce(), make_double_it(), make_print_it()
bind_network<DemoNodes>(m);
// ── Concrete C++ node wrappers ─────────────────────────────────────────────
// Factories return shared_ptr so Python can pass them to net.add().
nb::class_<ProduceNode, IVariantNode<KpnVariant>>(m, "ProduceNode")
.def("__init__", [](ProduceNode* self, std::size_t cap) {
new (self) ProduceNode(cap);
}, 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"));
// Also expose raw functions for direct testing (no network needed):
// kpn.produce() → 42
// kpn.double_it(5) → 10
// kpn.print_it(84) → prints "result: 84", returns None
bind_debug<DemoNodes>(m);
}
+121
View 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()
+1 -1
View File
@@ -1,4 +1,4 @@
// network.cpp — orchestrator/watchdog implementation details.
// network.cpp — orchestrator/watchdog implementation details. (CI: pipeline trigger)
// Most of the Network class is header-only (template-heavy).
// Non-template implementation lives here once the watchdog grows
// beyond the stub in network.hpp.
+3
View File
@@ -32,6 +32,9 @@ add_executable(kpn_tests
test_node.cpp
test_network.cpp
test_static_network.cpp
test_shared_resource.cpp
test_pool_node.cpp
test_scheduler.cpp
)
target_link_libraries(kpn_tests PRIVATE
+80 -2
View File
@@ -1,6 +1,8 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <kpn/channel.hpp>
#include <thread>
#include <vector>
using namespace kpn;
@@ -70,13 +72,21 @@ TEST_CASE("push to disabled channel is silently dropped", "[channel]") {
REQUIRE(ch.size() == 0);
}
TEST_CASE("disable clears existing queue contents", "[channel]") {
TEST_CASE("disable stops accepting and unblocks pop", "[channel]") {
// With the SPSC lock-free ring, disable() does not drain the ring immediately;
// items are freed when the Channel is destroyed. What it must do is:
// 1. reject further pushes (drops silently)
// 2. unblock any waiting pop() (throws ChannelClosedError)
Channel<int> ch(5);
ch.push(1);
ch.push(2);
REQUIRE(ch.size() == 2);
ch.disable();
REQUIRE(ch.size() == 0);
// Further pushes are dropped
ch.push(3);
REQUIRE(ch.size() <= 2);
// pop() must throw even though items remain in the ring
REQUIRE_THROWS_AS(ch.pop(), ChannelClosedError);
}
TEST_CASE("enable re-accepts pushes after disable", "[channel]") {
@@ -97,3 +107,71 @@ TEST_CASE("large type stored as shared_ptr — no copy on pop", "[channel]") {
auto out = ch.pop();
REQUIRE(out.tag == 123);
}
// ── ChannelDataSize / bytes_pushed tests ─────────────────────────────────────
TEST_CASE("bytes_pushed uses sizeof(T) by default for POD type", "[channel][bandwidth]") {
Channel<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);
}
+102
View File
@@ -2,6 +2,7 @@
#include <kpn/node.hpp>
#include <chrono>
#include <thread>
#include <utility>
using namespace kpn;
@@ -47,3 +48,104 @@ TEST_CASE("node stop unblocks cleanly", "[node]") {
node.stop();
REQUIRE_FALSE(node.running());
}
// ── Error handler tests ───────────────────────────────────────────────────────
namespace {
struct SometimesThrower {
bool throw_next = true;
int operator()(int x) {
if (std::exchange(throw_next, false))
throw std::runtime_error("deliberate skip");
return x * 2;
}
};
struct AlwaysThrows {
int operator()(int) { throw std::runtime_error("always throws"); return 0; }
};
static int always_throws_fn(int) {
throw std::runtime_error("nttp always throws");
return 0;
}
} // namespace
TEST_CASE("node stops cleanly on exception with no error handler", "[node][error_handler]") {
AlwaysThrows obj;
auto node = make_node(obj);
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE_FALSE(node.running());
}
TEST_CASE("node continues when error handler returns true", "[node][error_handler]") {
SometimesThrower obj;
auto node = make_node(obj);
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
bool handler_called = false;
node.set_error_handler([&](std::string_view, std::exception_ptr) {
handler_called = true;
return true;
});
node.start();
node.input_channel<0>().push(0); // throws → skipped, no output
node.input_channel<0>().push(21); // succeeds → 42
int result = out_ch.pop(); // blocking — waits for the second item
node.stop();
REQUIRE(handler_called);
REQUIRE(result == 42);
}
TEST_CASE("node stops when error handler returns false", "[node][error_handler]") {
AlwaysThrows obj;
auto node = make_node(obj);
node.set_error_handler([](std::string_view, std::exception_ptr) { return false; });
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE_FALSE(node.running());
}
TEST_CASE("error handler receives node name and exception", "[node][error_handler]") {
AlwaysThrows obj;
auto node = make_node(obj);
node.set_name("test_node");
std::string captured_name;
std::string captured_msg;
node.set_error_handler([&](std::string_view name, std::exception_ptr ep) {
captured_name = name;
try { std::rethrow_exception(ep); }
catch (const std::exception& e) { captured_msg = e.what(); }
return false;
});
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
REQUIRE(captured_name == "test_node");
REQUIRE(captured_msg == "always throws");
}
TEST_CASE("Node<NTTP> stops cleanly on exception with no error handler", "[node][error_handler]") {
auto node = make_node<always_throws_fn>();
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE_FALSE(node.running());
}
+464
View File
@@ -0,0 +1,464 @@
#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 <mutex>
#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();
}
// ── Overflow callback ─────────────────────────────────────────────────────────
TEST_CASE("pool node overflow callback fires on full output channel", "[pool_node][overflow]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool);
// Pre-fill a tiny channel so every node push overflows.
Channel<int> full_ch(1);
full_ch.push(99);
node.set_output_channel<0>(&full_ch);
std::atomic<int> overflow_count{0};
node.set_overflow_callback([&](auto) { overflow_count.fetch_add(1); });
node.start();
node.input_channel<0>().push(1);
node.input_channel<0>().push(2);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
pool->stop();
REQUIRE(overflow_count.load() > 0);
}
TEST_CASE("pool node overflow callback is independent per instance", "[pool_node][overflow]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto nodeA = make_pool_node<double_it>(pool);
auto nodeB = make_pool_node<double_it>(pool);
std::atomic<int> a_overflows{0}, b_overflows{0};
nodeA.set_overflow_callback([&](auto) { a_overflows.fetch_add(1); });
Channel<int> full_ch(1);
full_ch.push(0);
nodeA.set_output_channel<0>(&full_ch);
Channel<int> ok_ch(20);
nodeB.set_output_channel<0>(&ok_ch);
nodeA.start();
nodeB.start();
nodeA.input_channel<0>().push(1);
nodeA.input_channel<0>().push(2);
nodeB.input_channel<0>().push(10);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
nodeA.stop();
nodeB.stop();
pool->stop();
REQUIRE(a_overflows.load() > 0);
REQUIRE(b_overflows.load() == 0);
}
TEST_CASE("interrupt node overflow callback fires on full output", "[interrupt_node][overflow]") {
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> full_ch(1);
full_ch.push(99);
node.set_output_channel<0>(&full_ch);
std::atomic<int> overflow_count{0};
node.set_overflow_callback([&](auto) { overflow_count.fetch_add(1); });
node.start();
auto trigger = node.get_trigger();
trigger(); trigger(); trigger();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
pool->stop();
REQUIRE(overflow_count.load() > 0);
}
// ── self_stop: disable inputs + outputs on crash ──────────────────────────────
static int always_throw(int) { throw std::runtime_error("node crashed"); return 0; }
TEST_CASE("pool node self_stop disables output on crash so downstream sees closed", "[pool_node][self_stop]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<always_throw>(pool, 5);
Channel<int> out_ch(10);
node.set_output_channel<0>(&out_ch);
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE_FALSE(out_ch.is_accepting());
node.stop();
pool->stop();
}
TEST_CASE("pool node self_stop disables input on crash", "[pool_node][self_stop]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<always_throw>(pool, 5);
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE_FALSE(node.input_channel<0>().is_accepting());
node.stop();
pool->stop();
}
TEST_CASE("pool node closed callback fires on self_stop from crash", "[pool_node][self_stop]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<always_throw>(pool, 5);
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
std::atomic<bool> closed_fired{false};
node.set_closed_callback([&](auto) { closed_fired.store(true); });
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE(closed_fired.load());
node.stop();
pool->stop();
}
// ── Network-level event callbacks ─────────────────────────────────────────────
TEST_CASE("network_overflow_callback fires on overflow", "[pool_node][network]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool);
Channel<int> full_ch(1);
full_ch.push(0);
node.set_output_channel<0>(&full_ch);
std::atomic<int> net_overflows{0};
node.set_network_overflow_callback([&](auto) { net_overflows.fetch_add(1); });
node.start();
node.input_channel<0>().push(1);
node.input_channel<0>().push(2);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
pool->stop();
REQUIRE(net_overflows.load() > 0);
}
TEST_CASE("network_closed_callback fires on crash", "[pool_node][network]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<always_throw>(pool);
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
std::atomic<bool> net_closed{false};
node.set_network_closed_callback([&](auto) { net_closed.store(true); });
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE(net_closed.load());
node.stop();
pool->stop();
}
TEST_CASE("per-node and network overflow callbacks both fire independently", "[pool_node][network]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool);
Channel<int> full_ch(1);
full_ch.push(0);
node.set_output_channel<0>(&full_ch);
std::atomic<int> per_node{0}, network{0};
node.set_overflow_callback([&](auto) { per_node.fetch_add(1); });
node.set_network_overflow_callback([&](auto) { network.fetch_add(1); });
node.start();
node.input_channel<0>().push(1);
node.input_channel<0>().push(2);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
pool->stop();
REQUIRE(per_node.load() > 0);
REQUIRE(network.load() > 0);
}
+229
View 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();
}
+239
View File
@@ -0,0 +1,239 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <kpn/shared_resource.hpp>
#include <kpn/channel.hpp>
#include <atomic>
#include <chrono>
#include <thread>
#include <vector>
using namespace kpn;
using namespace std::chrono_literals;
// ── Basic acquire / release ───────────────────────────────────────────────────
TEST_CASE("acquire returns guard that accesses the resource", "[shared_resource]") {
SharedResource<int> res(42);
{
auto g = res.acquire();
REQUIRE(*g == 42);
*g = 99;
} // g released here — second acquire must not overlap in the same thread
{
auto g2 = res.acquire();
REQUIRE(*g2 == 99);
}
}
TEST_CASE("guard operator-> reaches resource members", "[shared_resource]") {
struct Pair { int x{1}; int y{2}; };
SharedResource<Pair> res;
auto g = res.acquire();
REQUIRE(g->x == 1);
REQUIRE(g->y == 2);
g->x = 10;
REQUIRE(g.get().x == 10);
}
TEST_CASE("guard releases on scope exit", "[shared_resource]") {
SharedResource<int> res(0);
{
auto g = res.acquire();
REQUIRE(res.snapshot("r").held);
}
// After guard destroyed, resource is free
REQUIRE(!res.snapshot("r").held);
}
// ── Mutual exclusion ──────────────────────────────────────────────────────────
TEST_CASE("only one thread holds the resource at a time", "[shared_resource]") {
SharedResource<int> res(0);
std::atomic<int> concurrent_holders{0};
std::atomic<int> violations{0};
std::atomic<bool> go{false};
auto worker = [&] {
while (!go.load()) std::this_thread::yield();
for (int i = 0; i < 20; ++i) {
auto g = res.acquire();
int h = concurrent_holders.fetch_add(1) + 1;
if (h > 1) violations.fetch_add(1);
std::this_thread::sleep_for(100us);
concurrent_holders.fetch_sub(1);
}
};
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) threads.emplace_back(worker);
go.store(true);
for (auto& t : threads) t.join();
REQUIRE(violations.load() == 0);
}
// ── Priority ordering ─────────────────────────────────────────────────────────
TEST_CASE("higher priority waiter is served before lower priority waiter", "[shared_resource]") {
SharedResource<int> res(0);
// Hold the resource so threads have to queue.
auto holder = res.acquire();
std::vector<int> order;
std::mutex order_mtx;
// Launch two waiters: low priority first, then high priority.
std::thread low([&] {
auto g = res.acquire([] { return 0.1f; });
std::lock_guard lk(order_mtx);
order.push_back(1);
});
std::this_thread::sleep_for(5ms); // ensure low is queued first
std::thread high([&] {
auto g = res.acquire([] { return 0.9f; });
std::lock_guard lk(order_mtx);
order.push_back(2);
});
std::this_thread::sleep_for(5ms); // ensure high is also queued
// Release — high priority should win even though low arrived first.
{ auto drop = std::move(holder); }
low.join();
high.join();
REQUIRE(order.size() == 2);
REQUIRE(order[0] == 2); // high priority served first
REQUIRE(order[1] == 1);
}
// ── acquire_balanced uses channel fills ───────────────────────────────────────
TEST_CASE("acquire_balanced: full input + empty output gives score ~1.0", "[shared_resource]") {
// We test the priority function indirectly via ordering.
// Node A: in=full, out=empty → score ≈ 1.0 (high)
// Node B: in=empty, out=full → score ≈ 0.0 (low)
Channel<int> in_a(4); // fill it
Channel<int> out_a(4); // leave empty
Channel<int> in_b(4); // leave empty
Channel<int> out_b(4); // fill it
in_a.enable(); out_a.enable();
in_b.enable(); out_b.enable();
for (int i = 0; i < 4; ++i) { in_a.push(i); out_b.push(i); }
SharedResource<int> res(0);
auto holder = res.acquire(); // block others
std::vector<int> order;
std::mutex mtx;
// Node B (low priority) waits first
std::thread tb([&] {
auto g = res.acquire_balanced(in_b, out_b);
std::lock_guard lk(mtx);
order.push_back(2);
});
std::this_thread::sleep_for(5ms);
// Node A (high priority) waits second
std::thread ta([&] {
auto g = res.acquire_balanced(in_a, out_a);
std::lock_guard lk(mtx);
order.push_back(1);
});
std::this_thread::sleep_for(5ms);
{ auto drop = std::move(holder); } // release
ta.join();
tb.join();
REQUIRE(order.size() == 2);
REQUIRE(order[0] == 1); // node A served first despite arriving second
}
// ── Statistics ────────────────────────────────────────────────────────────────
TEST_CASE("stats: acquisitions counted correctly", "[shared_resource]") {
SharedResource<int> res(0);
{
auto g1 = res.acquire();
}
{
auto g2 = res.acquire();
}
REQUIRE(res.snapshot("r").acquisitions == 2);
}
TEST_CASE("stats: peak_waiters reflects maximum concurrent queue depth", "[shared_resource]") {
SharedResource<int> res(0);
auto holder = res.acquire();
std::atomic<int> ready{0};
auto waiter = [&] {
ready.fetch_add(1);
auto g = res.acquire();
};
std::thread t1(waiter), t2(waiter), t3(waiter);
// Wait until all three are queued
while (ready.load() < 3) std::this_thread::sleep_for(1ms);
std::this_thread::sleep_for(5ms); // give them time to block on acquire
{ auto drop = std::move(holder); } // release
t1.join(); t2.join(); t3.join();
REQUIRE(res.snapshot("r").peak_waiters >= 2); // at least 2 queued simultaneously
}
TEST_CASE("stats: current_waiters returns to 0 after all served", "[shared_resource]") {
SharedResource<int> res(0);
auto holder = res.acquire();
std::thread t1([&] { auto g = res.acquire(); });
std::thread t2([&] { auto g = res.acquire(); });
std::this_thread::sleep_for(10ms);
{ auto drop = std::move(holder); }
t1.join(); t2.join();
REQUIRE(res.snapshot("r").current_waiters == 0);
}
TEST_CASE("stats: avg_wait_ms is positive when contention occurred", "[shared_resource]") {
SharedResource<int> res(0);
{
auto holder = res.acquire();
std::thread t([&] { auto g = res.acquire(); });
std::this_thread::sleep_for(10ms);
{ auto drop = std::move(holder); }
t.join();
}
REQUIRE(res.snapshot("r").avg_wait_ms > 0.0);
}
// ── No-arg acquire ────────────────────────────────────────────────────────────
TEST_CASE("no-arg acquire works and releases correctly", "[shared_resource]") {
SharedResource<int> res(7);
auto g = res.acquire();
REQUIRE(*g == 7);
REQUIRE(res.snapshot("r").held);
}
// ── make_shared_resource factory ──────────────────────────────────────────────
TEST_CASE("make_shared_resource constructs with forwarded args", "[shared_resource]") {
auto res = make_shared_resource<std::string>("hello");
auto g = res.acquire();
REQUIRE(*g == "hello");
}