Compare commits
No commits in common. "master" and "better-examples" have entirely different histories.
master
...
better-exa
@ -1,80 +0,0 @@
|
||||
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:
|
||||
# Explicit string, not a boolean expression (act_runner mangles bools).
|
||||
push: ${{ github.event_name == 'pull_request' && 'false' || 'true' }}
|
||||
|
||||
# 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
|
||||
@ -1,57 +0,0 @@
|
||||
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.
|
||||
# Note: `push` is a STRING ("true"/"false"), not a boolean. Gitea's act_runner
|
||||
# mangles boolean inputs passed from an expression (they arrive as false), so we
|
||||
# pass an explicit string and compare with == 'true' below.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
push:
|
||||
description: 'Push the built image to the registry ("true"/"false")'
|
||||
type: string
|
||||
default: 'true'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
description: 'Push the built image to the registry ("true"/"false")'
|
||||
type: string
|
||||
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 == 'true' }}
|
||||
run: |
|
||||
docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
|
||||
docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:${{ github.sha }}
|
||||
@ -1,34 +0,0 @@
|
||||
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 }}"
|
||||
@ -1,9 +1,18 @@
|
||||
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:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@ -32,7 +41,7 @@ jobs:
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DKPN_BUILD_TESTS=ON \
|
||||
-DKPN_BUILD_EXAMPLES=ON \
|
||||
-DKPN_BUILD_EXAMPLES=OFF \
|
||||
-DKPN_BUILD_PYTHON=ON \
|
||||
-DFETCHCONTENT_BASE_DIR=$HOME/.cmake/fetchcontent
|
||||
|
||||
@ -40,26 +49,18 @@ jobs:
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: cmake --build build --parallel
|
||||
|
||||
- name: Run unit tests
|
||||
- name: Run tests
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: |
|
||||
cd build
|
||||
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
|
||||
ctest --output-on-failure --output-junit test-results.xml
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
test-${{ github.run_id }}/build/test-results.xml
|
||||
test-${{ github.run_id }}/build/example-results.xml
|
||||
path: test-${{ github.run_id }}/build/test-results.xml
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,8 +1,6 @@
|
||||
# Build output
|
||||
build/
|
||||
build_test/
|
||||
build_debug/
|
||||
site/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@ -48,12 +48,6 @@ 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)
|
||||
@ -75,14 +69,3 @@ 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()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# KPN++ Builder Image (CI: pipeline trigger v2)
|
||||
# KPN++ Builder Image
|
||||
# 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,12 +16,4 @@ 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
|
||||
|
||||
21
LICENSE
21
LICENSE
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Duncan Tourolle
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
427
README.md
427
README.md
@ -2,8 +2,6 @@
|
||||
|
||||
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.
|
||||
|
||||
📖 **[Documentation](https://pages.tourolle.paris/dtourolle/kpn/)**
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
@ -52,55 +50,31 @@ A node wraps any callable. Its input types are taken from the function's paramet
|
||||
```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):
|
||||
// Single input, single output
|
||||
int double_it(int x) { return x * 2; }
|
||||
|
||||
```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'; }
|
||||
```
|
||||
// Multi-output — must return std::tuple
|
||||
std::tuple<cv::Mat, cv::Mat> split(cv::Mat 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)};
|
||||
}
|
||||
// Sink — void return, no output ports
|
||||
void display(cv::Mat frame) { cv::imshow("out", frame); }
|
||||
```
|
||||
|
||||
### Creating Nodes
|
||||
|
||||
**Index-only ports** (from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp)):
|
||||
|
||||
```cpp
|
||||
auto src = make_node<produce>(5);
|
||||
auto dbl = make_node<double_it>(5);
|
||||
auto sink = make_node<print_it>(5);
|
||||
```
|
||||
// No port names (index-only access)
|
||||
auto node = make_node<double_it>(/*fifo_capacity=*/5);
|
||||
|
||||
**Named ports** (from [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp)):
|
||||
// Named input ports only
|
||||
auto node = make_node<double_it>(in<"value">{}, 5);
|
||||
|
||||
```cpp
|
||||
// tokenise: no inputs, one named output "words"
|
||||
auto tok = make_node<tokenise>(out<"words">{}, 4);
|
||||
// Named input and output ports
|
||||
auto node = make_node<double_it>(in<"value">{}, out<"result">{}, 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);
|
||||
// Named output ports only (e.g. a source with no inputs)
|
||||
auto node = make_node<capture>(out<"colour","grey">{}, 5);
|
||||
```
|
||||
|
||||
Port names are NTTP `fixed_string` values — resolved entirely at compile time, zero runtime cost.
|
||||
@ -109,25 +83,22 @@ 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.
|
||||
|
||||
From [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp):
|
||||
|
||||
```cpp
|
||||
auto src = make_node<produce>(in<"x">{}, out<"value">{}, 5);
|
||||
auto proc = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
|
||||
|
||||
Network net;
|
||||
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.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.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
// ... do work ...
|
||||
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
|
||||
@ -142,36 +113,14 @@ 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 (from [`examples/04_storage_policy/main.cpp`](examples/04_storage_policy/main.cpp)):
|
||||
Override the policy for a specific type:
|
||||
|
||||
```cpp
|
||||
// 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> {
|
||||
template<> struct kpn::channel_storage_policy<MyType> {
|
||||
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()`:
|
||||
@ -222,155 +171,20 @@ 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):
|
||||
**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:
|
||||
|
||||
```cpp
|
||||
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";
|
||||
}
|
||||
}
|
||||
Channel<cv::Mat> result_ch(8);
|
||||
comp.set_output_channel<0>(&result_ch);
|
||||
// ... build and start network ...
|
||||
|
||||
// Main thread display loop
|
||||
while (true) {
|
||||
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));
|
||||
if (!result_ch.try_pop(frame, std::chrono::milliseconds(100))) continue;
|
||||
cv::imshow("output", frame);
|
||||
if (cv::waitKey(1) == 'q') break;
|
||||
}
|
||||
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();
|
||||
```
|
||||
|
||||
@ -400,8 +214,8 @@ Violating the second rule deadlocks.
|
||||
| `04_storage_policy` | `channel_storage_policy` default and specialisation |
|
||||
| `05_error_handling` | `ChannelOverflowError`, `ErrorHandler` |
|
||||
| `06_watchdog` | Watchdog interval, stall detection |
|
||||
| `07_python_network` | PyNetwork with a pure-Python node between a C++ source and sink |
|
||||
| `08_python_subport` | Drive a Python node from Python via `net.write`/`net.read` sub-port taps |
|
||||
| `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:
|
||||
@ -414,143 +228,6 @@ Run the cell-shading example:
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
Measured on Linux (x86-64, `-O3 -march=native`) with `benchmarks/bench_pipeline`.
|
||||
Each topology pushes N items through the graph; `overhead_us/item` strips out the
|
||||
per-node compute time to isolate framework cost.
|
||||
|
||||
Overhead formula: `(elapsed − (N + depth − 1) × work_us) / N` removes the expected
|
||||
pipeline-fill cost so the number reflects pure framework latency.
|
||||
|
||||
### Baseline overhead (private pools, 100 µs/node)
|
||||
|
||||
| Topology | items/sec | overhead µs/item |
|
||||
|---|---|---|
|
||||
| chain depth-1 | 9 797 | ~2 |
|
||||
| chain depth-4 | 9 448 | ~4 |
|
||||
| chain depth-8 | 9 078 | ~7 |
|
||||
| chain depth-16 | 7 004 | ~13 ← oversubscription |
|
||||
| chain depth-32 | 4 179 | ~77 ← oversubscription |
|
||||
| wide fanout-1 | 9 751 | ~3 |
|
||||
| wide fanout-4 | 9 668 | ~3 |
|
||||
| diamond (2×2) | 9 607 | ~4 |
|
||||
|
||||
Chain overhead is flat at **~2–7 µs/hop** for depths within the machine's core count,
|
||||
then rises once threads compete for CPU. Wide and diamond topologies add no measurable
|
||||
overhead as fanout increases — all branches run in parallel.
|
||||
|
||||
### Scheduling modes
|
||||
|
||||
`Node<>` gives each node a private `ThreadPool(1)`. `PoolNode<>` lets multiple
|
||||
nodes share one pool. The right choice depends on the graph shape:
|
||||
|
||||
| Scenario | Recommended |
|
||||
|---|---|
|
||||
| Work per node < 100 µs, deep chain | Private pools — lower per-hop latency |
|
||||
| Work per node ≥ 100 µs, wide/diamond | Shared pool, `threads = hardware_concurrency` |
|
||||
| Any graph, bounded thread count required | Shared pool, `threads ≥ max parallel nodes` |
|
||||
|
||||
A shared single-thread pool (`threads=1`) fully serialises the graph — throughput
|
||||
divides by depth for chains and by width for fanout topologies. A shared pool with
|
||||
`threads ≥ max_concurrent_nodes` matches private-pool throughput while keeping the
|
||||
OS thread count bounded.
|
||||
|
||||
### vs. TBB flow graph
|
||||
|
||||
Benchmarked against `tbb::flow::function_node<int,int>` (serial concurrency) with
|
||||
`tbb::flow::broadcast_node<int>` for fanout. Run with `cmake -DKPN_BUILD_BENCHMARKS=ON`
|
||||
— TBB benchmarks are included automatically when `find_package(TBB)` succeeds.
|
||||
|
||||
**Channel implementation:** lock-free SPSC ring buffer with `std::atomic::wait/notify_one`
|
||||
(C++20 portable futex) plus a configurable spin-before-sleep window (default ~4 µs).
|
||||
Large types are stored as `shared_ptr<const T>` — fanout copies reference counts,
|
||||
not data.
|
||||
|
||||
Overhead µs/item at **work_us = 10** (framework overhead dominates):
|
||||
|
||||
| Topology | KPN++ | 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++ | 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++ 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
|
||||
|
||||
```
|
||||
@ -577,36 +254,4 @@ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. This project is hosted on a self-hosted Gitea
|
||||
instance that accepts sign-in and registration with a GitHub account, so you
|
||||
can log in with your existing GitHub identity to open issues and pull requests.
|
||||
|
||||
If you change any code that appears in a README snippet, edit `README.md.in`
|
||||
(the template) rather than `README.md` directly, then regenerate:
|
||||
|
||||
```bash
|
||||
cmake --build build --target readme # or: python scripts/render_readme.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
AI tooling was used heavily throughout the development of this project,
|
||||
including the design, implementation, tests, and documentation. All output
|
||||
has been reviewed, but please keep this in mind when reading or building on the
|
||||
code.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT License](LICENSE). Copyright (c) 2026 Duncan Tourolle.
|
||||
|
||||
434
README.md.in
434
README.md.in
@ -1,434 +0,0 @@
|
||||
# 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.
|
||||
|
||||
📖 **[Documentation](https://pages.tourolle.paris/dtourolle/kpn/)**
|
||||
|
||||
---
|
||||
|
||||
## 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 with a pure-Python node between a C++ source and sink |
|
||||
| `08_python_subport` | Drive a Python node from Python via `net.write`/`net.read` sub-port taps |
|
||||
| `09_opencv_cellshade` | Real-time cell-shading on webcam/pattern; requires OpenCV ≥ 4 |
|
||||
|
||||
Run the cell-shading example:
|
||||
|
||||
```bash
|
||||
./build/examples/09_opencv_cellshade
|
||||
# Press 'q' or close the window to stop.
|
||||
# Falls back to an animated synthetic pattern if no webcam is found.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
Measured on Linux (x86-64, `-O3 -march=native`) with `benchmarks/bench_pipeline`.
|
||||
Each topology pushes N items through the graph; `overhead_us/item` strips out the
|
||||
per-node compute time to isolate framework cost.
|
||||
|
||||
Overhead formula: `(elapsed − (N + depth − 1) × work_us) / N` removes the expected
|
||||
pipeline-fill cost so the number reflects pure framework latency.
|
||||
|
||||
### Baseline overhead (private pools, 100 µs/node)
|
||||
|
||||
| Topology | items/sec | overhead µs/item |
|
||||
|---|---|---|
|
||||
| chain depth-1 | 9 797 | ~2 |
|
||||
| chain depth-4 | 9 448 | ~4 |
|
||||
| chain depth-8 | 9 078 | ~7 |
|
||||
| chain depth-16 | 7 004 | ~13 ← oversubscription |
|
||||
| chain depth-32 | 4 179 | ~77 ← oversubscription |
|
||||
| wide fanout-1 | 9 751 | ~3 |
|
||||
| wide fanout-4 | 9 668 | ~3 |
|
||||
| diamond (2×2) | 9 607 | ~4 |
|
||||
|
||||
Chain overhead is flat at **~2–7 µs/hop** for depths within the machine's core count,
|
||||
then rises once threads compete for CPU. Wide and diamond topologies add no measurable
|
||||
overhead as fanout increases — all branches run in parallel.
|
||||
|
||||
### Scheduling modes
|
||||
|
||||
`Node<>` gives each node a private `ThreadPool(1)`. `PoolNode<>` lets multiple
|
||||
nodes share one pool. The right choice depends on the graph shape:
|
||||
|
||||
| Scenario | Recommended |
|
||||
|---|---|
|
||||
| Work per node < 100 µs, deep chain | Private pools — lower per-hop latency |
|
||||
| Work per node ≥ 100 µs, wide/diamond | Shared pool, `threads = hardware_concurrency` |
|
||||
| Any graph, bounded thread count required | Shared pool, `threads ≥ max parallel nodes` |
|
||||
|
||||
A shared single-thread pool (`threads=1`) fully serialises the graph — throughput
|
||||
divides by depth for chains and by width for fanout topologies. A shared pool with
|
||||
`threads ≥ max_concurrent_nodes` matches private-pool throughput while keeping the
|
||||
OS thread count bounded.
|
||||
|
||||
### vs. TBB flow graph
|
||||
|
||||
Benchmarked against `tbb::flow::function_node<int,int>` (serial concurrency) with
|
||||
`tbb::flow::broadcast_node<int>` for fanout. Run with `cmake -DKPN_BUILD_BENCHMARKS=ON`
|
||||
— TBB benchmarks are included automatically when `find_package(TBB)` succeeds.
|
||||
|
||||
**Channel implementation:** lock-free SPSC ring buffer with `std::atomic::wait/notify_one`
|
||||
(C++20 portable futex) plus a configurable spin-before-sleep window (default ~4 µs).
|
||||
Large types are stored as `shared_ptr<const T>` — fanout copies reference counts,
|
||||
not data.
|
||||
|
||||
Overhead µs/item at **work_us = 10** (framework overhead dominates):
|
||||
|
||||
| Topology | KPN++ | 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++ | 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++ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. This project is hosted on a self-hosted Gitea
|
||||
instance that accepts sign-in and registration with a GitHub account, so you
|
||||
can log in with your existing GitHub identity to open issues and pull requests.
|
||||
|
||||
If you change any code that appears in a README snippet, edit `README.md.in`
|
||||
(the template) rather than `README.md` directly, then regenerate:
|
||||
|
||||
```bash
|
||||
cmake --build build --target readme # or: python scripts/render_readme.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
AI tooling was used heavily throughout the development of this project,
|
||||
including the design, implementation, tests, and documentation. All output
|
||||
has been reviewed, but please keep this in mind when reading or building on the
|
||||
code.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT License](LICENSE). Copyright (c) 2026 Duncan Tourolle.
|
||||
@ -1,14 +0,0 @@
|
||||
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()
|
||||
@ -1,526 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
# 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);
|
||||
```
|
||||
@ -1,84 +0,0 @@
|
||||
# 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"
|
||||
```
|
||||
@ -1,40 +0,0 @@
|
||||
# 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.
|
||||
```
|
||||
@ -1,44 +0,0 @@
|
||||
# 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>());
|
||||
```
|
||||
@ -1,76 +0,0 @@
|
||||
# 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"
|
||||
```
|
||||
@ -1,39 +0,0 @@
|
||||
# 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.
|
||||
@ -1,67 +0,0 @@
|
||||
# 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).
|
||||
@ -1,81 +0,0 @@
|
||||
# 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.
|
||||
@ -1,3 +0,0 @@
|
||||
mkdocs>=1.5
|
||||
mkdocs-material>=9.5
|
||||
pymdown-extensions>=10.0
|
||||
@ -1,42 +0,0 @@
|
||||
# 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.
|
||||
@ -1,46 +0,0 @@
|
||||
# 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.
|
||||
@ -7,20 +7,16 @@
|
||||
//
|
||||
// [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>();
|
||||
@ -28,7 +24,6 @@ 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)
|
||||
@ -40,5 +35,4 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
net.stop();
|
||||
// --8<-- [end:network_build]
|
||||
}
|
||||
|
||||
@ -54,18 +54,17 @@ 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
|
||||
// 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
|
||||
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)
|
||||
@ -78,5 +77,4 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
net.stop();
|
||||
// --8<-- [end:named_port_network]
|
||||
}
|
||||
|
||||
@ -33,7 +33,6 @@ 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) {
|
||||
@ -41,7 +40,6 @@ 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';
|
||||
@ -56,7 +54,6 @@ 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);
|
||||
@ -75,5 +72,4 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(600));
|
||||
net.stop();
|
||||
// --8<-- [end:fanout_network]
|
||||
}
|
||||
|
||||
@ -34,14 +34,12 @@ 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -45,7 +45,6 @@ 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,
|
||||
@ -58,7 +57,6 @@ int main() {
|
||||
<< "overflows=" << c.overflows;
|
||||
std::cout << '\n';
|
||||
});
|
||||
// --8<-- [end:diagnostics_handler]
|
||||
|
||||
net.set_watchdog_interval(std::chrono::milliseconds(200));
|
||||
|
||||
|
||||
@ -30,8 +30,3 @@ net.build()
|
||||
net.start()
|
||||
time.sleep(0.1)
|
||||
net.stop()
|
||||
|
||||
# Drop the network deterministically: it holds the Python callable, which forms
|
||||
# a reference cycle via globals(). Deleting the global breaks it so the network
|
||||
# is reclaimed now rather than lingering to interpreter shutdown.
|
||||
del net
|
||||
|
||||
@ -1,55 +1,38 @@
|
||||
"""
|
||||
08_python_subport — drive a *Python* node from Python via write()/read() taps.
|
||||
08_python_subport — tap a C++ node's output from Python using net.read().
|
||||
|
||||
Graph:
|
||||
(fed by net.write()) --int--> [py_triple] --int--> (tapped by net.read())
|
||||
[ProduceNode] --int--> [DoubleItNode] --int--> (tapped by net.read())
|
||||
|
||||
Unlike 07, there is no C++ source or sink here: the only node in the network is
|
||||
a pure-Python function, py_triple. Python plays *both* the producer and the
|
||||
consumer by using the subport taps:
|
||||
|
||||
* net.write("py", 0, v) injects v into py_triple's input (Python -> network)
|
||||
* net.read("py", 0) pulls py_triple's output back out (network -> Python)
|
||||
|
||||
This closes the loop the old version left as a "#todo": a value flows from
|
||||
Python, through a Python node running inside the network, and back to Python.
|
||||
The sink is Python: instead of connecting a PrintItNode, we call net.read()
|
||||
to pull values out of DoubleItNode's output directly into Python.
|
||||
We also demonstrate net.write() by injecting a value into DoubleItNode's input.
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "build/python") # for `python examples/.../example.py` from repo root
|
||||
import time
|
||||
import threading
|
||||
sys.path.insert(0, "build/python")
|
||||
|
||||
import kpn_python as kpn
|
||||
|
||||
|
||||
def py_triple(x: int) -> int:
|
||||
return x * 3
|
||||
|
||||
|
||||
net = kpn.Network()
|
||||
|
||||
# The whole network is a single Python node with a tapped input and output.
|
||||
net.add_node("py", py_triple, inputs=["int"], outputs=["int"])
|
||||
net.add("src", kpn.make_produce())
|
||||
net.add("dbl", kpn.make_double_it())
|
||||
|
||||
net.connect("src", 0, "dbl", 0)
|
||||
net.build()
|
||||
net.start()
|
||||
|
||||
# Push values in from Python and read the Python node's results back out.
|
||||
inputs = [1, 2, 7, 10, 100]
|
||||
# Collect a few values from DoubleItNode's output via Python tap
|
||||
results = []
|
||||
for v in inputs:
|
||||
net.write("py", 0, v) # Python -> py_triple input
|
||||
results.append(net.read("py", 0)) # py_triple output -> Python
|
||||
for _ in range(5):
|
||||
val = net.read("dbl", 0)
|
||||
results.append(val)
|
||||
|
||||
net.stop()
|
||||
|
||||
print("inputs written from Python: ", inputs)
|
||||
print("outputs read from py_triple:", results)
|
||||
|
||||
expected = [v * 3 for v in inputs]
|
||||
assert results == expected, f"expected {expected}, got {results}"
|
||||
print("all correct (x * 3 computed by a Python node inside the network)")
|
||||
|
||||
# Drop the network deterministically. The network holds the Python callable,
|
||||
# which (via globals) forms a reference cycle; deleting the global breaks it so
|
||||
# the network is reclaimed promptly instead of lingering to interpreter exit.
|
||||
del net
|
||||
print("values read from C++ DoubleItNode output:", results)
|
||||
assert all(v == 84 for v in results), f"expected all 84, got {results}"
|
||||
print("all correct (42 * 2 = 84)")
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@ -1,177 +0,0 @@
|
||||
#define KPN_BUILD_PYTHON
|
||||
#include <kpn/python/auto_bind.hpp>
|
||||
|
||||
#include <nanobind/ndarray.h>
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
#include <opencv2/imgproc.hpp>
|
||||
#include <opencv2/videoio.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
|
||||
namespace nb = nanobind;
|
||||
using namespace kpn;
|
||||
using namespace kpn::python;
|
||||
|
||||
// ── PythonConverter<cv::Mat> ──────────────────────────────────────────────────
|
||||
// Converts cv::Mat ↔ numpy array (uint8, HxW or HxWxC shape).
|
||||
//
|
||||
// to_python: clones the mat onto the heap; the numpy array owns it via a
|
||||
// capsule deleter — no shared cv::Mat refcount dangling after the Variant dies.
|
||||
// from_python: calls numpy.ascontiguousarray, then clones into an owned cv::Mat.
|
||||
|
||||
namespace kpn {
|
||||
|
||||
template<> struct PythonConverter<cv::Mat> {
|
||||
static constexpr const char* type_name = "mat";
|
||||
|
||||
static nb::object to_python(const cv::Mat& m) {
|
||||
// Must be called with the GIL held (always true: called from read() or
|
||||
// from within the gil_scoped_acquire block in PyNode::run_loop).
|
||||
auto np = nb::module_::import_("numpy");
|
||||
cv::Mat c = m.clone(); // ensure contiguous, independently owned
|
||||
nb::bytes raw(reinterpret_cast<const char*>(c.data),
|
||||
c.total() * c.elemSize());
|
||||
nb::object arr = np.attr("frombuffer")(raw, "uint8");
|
||||
int H = c.rows, W = c.cols, C = c.channels();
|
||||
arr = arr.attr("reshape")(
|
||||
C > 1 ? nb::make_tuple(H, W, C) : nb::make_tuple(H, W));
|
||||
return arr.attr("copy")(); // writable, lifetime-independent copy
|
||||
}
|
||||
|
||||
static cv::Mat from_python(nb::object o) {
|
||||
auto np = nb::module_::import_("numpy");
|
||||
// Ensure contiguous uint8 layout (in-place if already compatible)
|
||||
nb::object arr = np.attr("ascontiguousarray")(o, "uint8");
|
||||
auto shape = nb::cast<std::vector<int>>(arr.attr("shape"));
|
||||
if (shape.size() < 2 || shape.size() > 3)
|
||||
throw std::runtime_error(
|
||||
"cv::Mat from_python: expected 2D (H×W) or 3D (H×W×C) uint8 array");
|
||||
int H = shape[0], W = shape[1];
|
||||
int C = (shape.size() == 3) ? shape[2] : 1;
|
||||
int type = C > 1 ? CV_8UC(C) : CV_8UC1;
|
||||
|
||||
// Cast to ndarray to get the raw data pointer
|
||||
auto binfo = nb::cast<nb::ndarray<nb::numpy, uint8_t>>(arr);
|
||||
cv::Mat wrap(H, W, type, binfo.data());
|
||||
return wrap.clone(); // own the pixel data
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
// ── Pipeline functions ────────────────────────────────────────────────────────
|
||||
|
||||
static cv::Mat make_gradient(int W, int H) {
|
||||
cv::Mat xr(H, W, CV_8UC1), yg(H, W, CV_8UC1), b(H, W, CV_8UC1, cv::Scalar(128));
|
||||
for (int x = 0; x < W; ++x) xr.col(x).setTo(x * 255 / W);
|
||||
for (int y = 0; y < H; ++y) yg.row(y).setTo(y * 255 / H);
|
||||
cv::Mat channels[3] = {b, yg, xr};
|
||||
cv::Mat grad;
|
||||
cv::merge(channels, 3, grad);
|
||||
return grad;
|
||||
}
|
||||
|
||||
static std::tuple<cv::Mat, cv::Mat> capture() {
|
||||
constexpr int W = 640, H = 480;
|
||||
static cv::VideoCapture cap;
|
||||
static bool opened = false;
|
||||
if (!opened) {
|
||||
opened = true;
|
||||
cap.open(0, cv::CAP_V4L2);
|
||||
if (cap.isOpened()) {
|
||||
cap.set(cv::CAP_PROP_FRAME_WIDTH, W);
|
||||
cap.set(cv::CAP_PROP_FRAME_HEIGHT, H);
|
||||
} else {
|
||||
std::cerr << "[capture] no webcam — using synthetic animated pattern\n";
|
||||
}
|
||||
}
|
||||
|
||||
cv::Mat frame;
|
||||
if (cap.isOpened()) {
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
cap >> frame;
|
||||
auto elapsed = std::chrono::steady_clock::now() - t0;
|
||||
if (elapsed < std::chrono::milliseconds(20))
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed);
|
||||
if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3);
|
||||
} else {
|
||||
static int tick = 0;
|
||||
static cv::Mat grad = make_gradient(W, H);
|
||||
++tick;
|
||||
frame = grad.clone();
|
||||
int r = 150 + (tick % 80) * 4;
|
||||
cv::circle(frame, {W/2, H/2}, r, {255, 200, 0}, -1);
|
||||
cv::circle(frame, {W/2, H/2}, r / 2, { 0, 128, 255}, -1);
|
||||
cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200, 0, 200}, -1);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(33));
|
||||
}
|
||||
return {frame.clone(), frame.clone()};
|
||||
}
|
||||
|
||||
static cv::Mat to_gray(cv::Mat bgr) {
|
||||
cv::Mat gray;
|
||||
cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);
|
||||
return gray;
|
||||
}
|
||||
|
||||
static cv::Mat edges_fn(cv::Mat gray) {
|
||||
cv::Mat blurred, mask;
|
||||
cv::GaussianBlur(gray, blurred, {5, 5}, 0);
|
||||
cv::Canny(blurred, mask, 50, 150);
|
||||
return mask;
|
||||
}
|
||||
|
||||
static cv::Mat quantise(cv::Mat bgr) {
|
||||
constexpr int levels = 4;
|
||||
constexpr double step = 256.0 / levels;
|
||||
static const cv::Mat lut = []() {
|
||||
cv::Mat l(1, 256, CV_8UC1);
|
||||
for (int i = 0; i < 256; ++i)
|
||||
l.at<uchar>(i) = cv::saturate_cast<uchar>(
|
||||
std::floor(i / step) * step + step / 2.0);
|
||||
return l;
|
||||
}();
|
||||
cv::Mat out;
|
||||
cv::LUT(bgr, lut, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Returns composite frame AND edge mask so the display node can show both
|
||||
// without needing a fan-out on the edges channel.
|
||||
static std::tuple<cv::Mat, cv::Mat> composite(cv::Mat edge_mask, cv::Mat colour) {
|
||||
cv::Mat result = colour.clone();
|
||||
result.setTo(cv::Scalar(0, 0, 0), edge_mask);
|
||||
return {result, edge_mask};
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
// Variant deduced as std::variant<cv::Mat> — every node uses only cv::Mat.
|
||||
|
||||
using CvNodes = NodeRegistry<
|
||||
Entry<capture, "capture">,
|
||||
Entry<to_gray, "to_gray">,
|
||||
Entry<edges_fn, "edges">,
|
||||
Entry<quantise, "quantise">,
|
||||
Entry<composite, "composite">
|
||||
>;
|
||||
|
||||
// ── Module ────────────────────────────────────────────────────────────────────
|
||||
|
||||
NB_MODULE(kpn_opencv, m) {
|
||||
m.doc() = "KPN++ OpenCV bindings for the cell-shading pipeline";
|
||||
|
||||
// Registers: Network, INode, CaptureNode, ToGrayNode, EdgesNode,
|
||||
// QuantiseNode, CompositeNode, and make_<name>() factories.
|
||||
// Network.add_node(name, callable, inputs=["mat"], outputs=["mat"])
|
||||
// accepts Python callables that receive/return numpy uint8 arrays.
|
||||
bind_network<CvNodes>(m);
|
||||
|
||||
// Note: bind_debug is omitted here — cv::Mat functions cannot be called
|
||||
// directly from Python without the variant/network machinery. Use
|
||||
// net.write() + net.read() to inject/inspect individual nodes instead.
|
||||
}
|
||||
@ -8,12 +8,6 @@
|
||||
#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] ──────────────────────────┐
|
||||
@ -38,7 +32,6 @@ 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;
|
||||
@ -75,7 +68,6 @@ 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;
|
||||
@ -120,7 +112,6 @@ 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> {
|
||||
@ -150,14 +141,12 @@ 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);
|
||||
@ -182,17 +171,13 @@ 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.
|
||||
@ -201,6 +186,5 @@ int main() {
|
||||
cv::waitKey(8); // yield event loop when no frame ready
|
||||
|
||||
net.stop();
|
||||
// --8<-- [end:main_thread_step]
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -1,159 +0,0 @@
|
||||
// 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
|
||||
@ -1,75 +0,0 @@
|
||||
// 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';
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
// 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';
|
||||
}
|
||||
@ -1,35 +1,8 @@
|
||||
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()
|
||||
|
||||
# Register a Python example script as a CTest smoke test. Runs the script with
|
||||
# PYTHONPATH pointing at the freshly-built kpn_python module, so it does not
|
||||
# depend on the caller's working directory or a hard-coded "build/python" path.
|
||||
function(kpn_python_example name)
|
||||
if(NOT KPN_BUILD_PYTHON)
|
||||
return()
|
||||
endif()
|
||||
add_test(
|
||||
NAME example_${name}
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
"PYTHONPATH=$<TARGET_FILE_DIR:kpn_python>"
|
||||
${Python_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/${name}/example.py
|
||||
)
|
||||
set_tests_properties(example_${name} PROPERTIES
|
||||
TIMEOUT 15
|
||||
LABELS examples
|
||||
)
|
||||
endfunction()
|
||||
|
||||
kpn_example(01_hello_pipeline)
|
||||
@ -38,32 +11,19 @@ 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 are Python scripts — no compiled target, but run as smoke tests.
|
||||
kpn_python_example(07_python_network)
|
||||
kpn_python_example(08_python_subport)
|
||||
# 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()
|
||||
|
||||
# 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})
|
||||
|
||||
|
||||
@ -1,301 +0,0 @@
|
||||
#pragma once
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
#include "traits.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── RouterNode ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reads one item and pushes it to exactly one of N output channels, chosen by
|
||||
// selector(item). If selector returns >= N the item is silently dropped.
|
||||
//
|
||||
// Usage:
|
||||
// auto router = make_router<Image, 3>(
|
||||
// [](const Image& img) -> std::size_t { return img.stream_id % 3; });
|
||||
// net.connect("src", src.output<0>(), "router", router.input<0>())
|
||||
// .connect("router", router.output<0>(), "nodeA", nodeA.input<0>())
|
||||
// .connect("router", router.output<1>(), "nodeB", nodeB.input<0>())
|
||||
// .connect("router", router.output<2>(), "nodeC", nodeC.input<0>());
|
||||
|
||||
template<typename T, std::size_t N, std::size_t Id = 0>
|
||||
class RouterNode : public INode {
|
||||
public:
|
||||
using Selector = std::function<std::size_t(const T&)>;
|
||||
using args_tuple = std::tuple<T>;
|
||||
using return_tuple = repeat_tuple_t<T, N>;
|
||||
using return_raw = return_tuple;
|
||||
|
||||
static constexpr std::size_t input_count = 1;
|
||||
static constexpr std::size_t output_count = N;
|
||||
static constexpr std::size_t unique_tag = Id;
|
||||
static constexpr bool is_router_node = true;
|
||||
|
||||
explicit RouterNode(Selector sel, std::size_t fifo_capacity = 5)
|
||||
: selector_(std::move(sel))
|
||||
, fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
input_ch_ = std::make_shared<Channel<T>>(fifo_capacity);
|
||||
}
|
||||
|
||||
~RouterNode() override { stop(); }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void start() override {
|
||||
input_ch_->enable();
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
input_ch_->disable();
|
||||
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
||||
}
|
||||
|
||||
bool running() const override {
|
||||
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms + blocked_ms;
|
||||
return {name, frames, exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
blocked_ms,
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0};
|
||||
}
|
||||
|
||||
// ── Port access ───────────────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I = 0>
|
||||
InputPort<RouterNode, I> input() {
|
||||
static_assert(I == 0, "RouterNode has exactly one input");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
OutputPort<RouterNode, I> output() {
|
||||
static_assert(I < N, "RouterNode output index out of range");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
// ── Internal channel accessors (called by Network::connect) ───────────────
|
||||
|
||||
template<std::size_t I>
|
||||
Channel<T>& input_channel() {
|
||||
static_assert(I == 0);
|
||||
return *input_ch_;
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_input_channel(std::shared_ptr<Channel<T>> ch) {
|
||||
static_assert(I == 0);
|
||||
input_ch_ = std::move(ch);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_channel(Channel<T>* ch) {
|
||||
static_assert(I < N);
|
||||
out_channels_[I] = ch;
|
||||
}
|
||||
|
||||
private:
|
||||
void run_loop() {
|
||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
auto t0 = clock_t::now();
|
||||
T val = input_ch_->pop();
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
std::size_t idx = selector_(val);
|
||||
if (idx < N && out_channels_[idx]) {
|
||||
try { out_channels_[idx]->push(val); }
|
||||
catch (const ChannelOverflowError&) {}
|
||||
}
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
Selector selector_;
|
||||
std::shared_ptr<Channel<T>> input_ch_;
|
||||
std::array<Channel<T>*, N> out_channels_{};
|
||||
std::atomic<bool> stop_flag_{false};
|
||||
std::jthread thread_;
|
||||
NodeStats stats_;
|
||||
};
|
||||
|
||||
// ── FilterNode ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reads one item and pushes it downstream only when pred(item) is true.
|
||||
// Dropped items are not counted as processed frames.
|
||||
//
|
||||
// Usage:
|
||||
// auto filt = make_filter<Frame>([](const Frame& f) { return f.valid; });
|
||||
// net.connect("src", src.output<0>(), "filt", filt.input<0>())
|
||||
// .connect("filt", filt.output<0>(), "dst", dst.input<0>());
|
||||
|
||||
template<typename T, std::size_t Id = 0>
|
||||
class FilterNode : public INode {
|
||||
public:
|
||||
using Predicate = std::function<bool(const T&)>;
|
||||
using args_tuple = std::tuple<T>;
|
||||
using return_tuple = std::tuple<T>;
|
||||
using return_raw = return_tuple;
|
||||
|
||||
static constexpr std::size_t input_count = 1;
|
||||
static constexpr std::size_t output_count = 1;
|
||||
static constexpr std::size_t unique_tag = Id;
|
||||
static constexpr bool is_filter_node = true;
|
||||
|
||||
explicit FilterNode(Predicate pred, std::size_t fifo_capacity = 5)
|
||||
: pred_(std::move(pred))
|
||||
, fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
input_ch_ = std::make_shared<Channel<T>>(fifo_capacity);
|
||||
}
|
||||
|
||||
~FilterNode() override { stop(); }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void start() override {
|
||||
input_ch_->enable();
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
input_ch_->disable();
|
||||
if (thread_.joinable()) thread_.request_stop(), thread_.join();
|
||||
}
|
||||
|
||||
bool running() const override {
|
||||
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void set_name(std::string name) override { name_ = std::move(name); }
|
||||
|
||||
const NodeStats& stats() const override { return stats_; }
|
||||
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
|
||||
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
|
||||
double total_ms = exec_ms + blocked_ms;
|
||||
return {name, frames, exec_ms,
|
||||
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
blocked_ms,
|
||||
elapsed_s > 0 ? frames / elapsed_s : 0.0,
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0};
|
||||
}
|
||||
|
||||
// ── Port access ───────────────────────────────────────────────────────────
|
||||
|
||||
template<std::size_t I = 0>
|
||||
InputPort<FilterNode, I> input() {
|
||||
static_assert(I == 0, "FilterNode has exactly one input");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
template<std::size_t I = 0>
|
||||
OutputPort<FilterNode, I> output() {
|
||||
static_assert(I == 0, "FilterNode has exactly one output");
|
||||
return {*this};
|
||||
}
|
||||
|
||||
// ── Internal channel accessors (called by Network::connect) ───────────────
|
||||
|
||||
template<std::size_t I>
|
||||
Channel<T>& input_channel() {
|
||||
static_assert(I == 0);
|
||||
return *input_ch_;
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_input_channel(std::shared_ptr<Channel<T>> ch) {
|
||||
static_assert(I == 0);
|
||||
input_ch_ = std::move(ch);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_channel(Channel<T>* ch) {
|
||||
static_assert(I == 0);
|
||||
out_ch_ = ch;
|
||||
}
|
||||
|
||||
private:
|
||||
void run_loop() {
|
||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
auto t0 = clock_t::now();
|
||||
T val = input_ch_->pop();
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
if (pred_(val) && out_ch_) {
|
||||
try { out_ch_->push(val); }
|
||||
catch (const ChannelOverflowError&) {}
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
}
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
Predicate pred_;
|
||||
std::shared_ptr<Channel<T>> input_ch_;
|
||||
Channel<T>* out_ch_{nullptr};
|
||||
std::atomic<bool> stop_flag_{false};
|
||||
std::jthread thread_;
|
||||
NodeStats stats_;
|
||||
};
|
||||
|
||||
// ── Factories ─────────────────────────────────────────────────────────────────
|
||||
|
||||
template<typename T, std::size_t N>
|
||||
RouterNode<T, N> make_router(std::function<std::size_t(const T&)> sel,
|
||||
std::size_t capacity = 5) {
|
||||
return RouterNode<T, N, 0>(std::move(sel), capacity);
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
FilterNode<T> make_filter(std::function<bool(const T&)> pred,
|
||||
std::size_t capacity = 5) {
|
||||
return FilterNode<T, 0>(std::move(pred), capacity);
|
||||
}
|
||||
|
||||
} // namespace kpn
|
||||
@ -2,30 +2,16 @@
|
||||
#include "diagnostics.hpp"
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <condition_variable>
|
||||
#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>
|
||||
@ -58,183 +44,67 @@ 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, 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);
|
||||
}
|
||||
explicit Channel(std::size_t capacity = 5) : capacity_(capacity) {}
|
||||
|
||||
Channel(const Channel&) = delete;
|
||||
Channel& operator=(const Channel&) = delete;
|
||||
|
||||
// Push a value.
|
||||
// - If channel is disabled (accepting_ == false): silently drop.
|
||||
// - If channel is full (fill >= capacity_): throw ChannelOverflowError.
|
||||
// - If channel is disabled (accepting_ == false): silently drop, return immediately.
|
||||
// - If channel is full: throw ChannelOverflowError.
|
||||
void push(T value) {
|
||||
if (!accepting_.load(std::memory_order_relaxed)) {
|
||||
stats_.record_drop();
|
||||
return;
|
||||
}
|
||||
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)) {
|
||||
std::unique_lock lock(mutex_);
|
||||
if (!accepting_.load(std::memory_order_relaxed)) {
|
||||
stats_.record_drop();
|
||||
return;
|
||||
}
|
||||
if (t - h >= capacity_) {
|
||||
if (queue_.size() >= capacity_) {
|
||||
stats_.record_overflow();
|
||||
throw ChannelOverflowError(capacity_);
|
||||
}
|
||||
|
||||
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_();
|
||||
queue_.push(make_storage(std::move(value)));
|
||||
stats_.record_push(queue_.size());
|
||||
lock.unlock();
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
// Lossless, non-blocking delivery for a must-deliver control token (EOF).
|
||||
//
|
||||
// A sentinel is stored out-of-band — in a dedicated slot that does NOT
|
||||
// consume ring capacity — so this can never overflow and never blocks the
|
||||
// caller. That distinction is essential: each KPN node has a single worker
|
||||
// thread, so a *blocking* push would park that thread and stop it draining
|
||||
// its own input, cascading into a hold-and-wait deadlock under backpressure.
|
||||
// Setting a flag and returning keeps the worker free to keep popping.
|
||||
//
|
||||
// The consumer's pop() drains the ring first, then delivers this sentinel,
|
||||
// preserving ordering (EOF arrives after all data pushed before it).
|
||||
//
|
||||
// Only the sole producer may call it (SPSC contract, same as push()).
|
||||
// Returns false if the channel is already disabled (token discarded —
|
||||
// teardown is in progress, so the sentinel is moot).
|
||||
bool push_sentinel(T value) {
|
||||
if (!accepting_.load(std::memory_order_acquire)) {
|
||||
stats_.record_drop();
|
||||
return false;
|
||||
}
|
||||
eof_value_ = make_storage(std::move(value));
|
||||
has_eof_.store(true, std::memory_order_release);
|
||||
// Wake a consumer blocked in pop(): the sentinel is now deliverable even
|
||||
// though the ring may be empty.
|
||||
wake_.fetch_add(1, std::memory_order_release);
|
||||
wake_.notify_one();
|
||||
if (push_callback_) push_callback_();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Blocking pop. Returns when an item is available.
|
||||
// Throws ChannelClosedError if the channel is disabled (regardless of fill).
|
||||
// Blocking pop. Unblocks when an item is available or the channel is disabled.
|
||||
// Throws ChannelClosedError if disabled and queue is empty.
|
||||
T pop() {
|
||||
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) {
|
||||
// Ring drained — deliver any pending out-of-band sentinel (EOF)
|
||||
// now, so it always arrives after the data pushed before it.
|
||||
{ T s; if (take_sentinel(s)) return s; }
|
||||
|
||||
if (!accepting_.load(std::memory_order_acquire))
|
||||
std::unique_lock lock(mutex_);
|
||||
cv_.wait(lock, [this] {
|
||||
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
|
||||
});
|
||||
if (queue_.empty())
|
||||
throw ChannelClosedError{};
|
||||
|
||||
for (std::size_t si = 0; si < spin_count_; ++si) {
|
||||
spin_hint();
|
||||
t = tail_.load(std::memory_order_acquire);
|
||||
if (t != h) break;
|
||||
{ T s; if (take_sentinel(s)) return s; }
|
||||
if (!accepting_.load(std::memory_order_relaxed))
|
||||
throw ChannelClosedError{};
|
||||
}
|
||||
|
||||
if (h == t) {
|
||||
// Still empty after spin — sleep until push()/push_sentinel()
|
||||
// or disable() fires. Re-check tail and the sentinel after
|
||||
// loading w to guard against a lost wakeup.
|
||||
if (tail_.load(std::memory_order_acquire) != h) continue;
|
||||
if (has_eof_.load(std::memory_order_acquire)) 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);
|
||||
T value = extract(std::move(queue_.front()));
|
||||
queue_.pop();
|
||||
stats_.record_pop();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-blocking pop with timeout. For watchdog/display use only.
|
||||
// Non-blocking pop with timeout. For watchdog/display use only — not used in run_loop.
|
||||
bool try_pop(T& out, std::chrono::milliseconds timeout) {
|
||||
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.
|
||||
// Once the ring is drained, delivers any pending out-of-band sentinel (EOF)
|
||||
// so pool nodes — which pop only via this path — still receive the token.
|
||||
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 take_sentinel(out);
|
||||
out = extract(std::move(buf_[h & ring_mask_]));
|
||||
head_.store(h + 1, std::memory_order_release);
|
||||
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();
|
||||
stats_.record_pop();
|
||||
return true;
|
||||
}
|
||||
@ -244,33 +114,20 @@ public:
|
||||
accepting_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Disable the channel: drop all queued items, unblock any waiting pop().
|
||||
// Called by consumer node on stop(). Producer push() will silently drop after this.
|
||||
void disable() {
|
||||
accepting_.store(false, std::memory_order_release);
|
||||
wake_.fetch_add(1, std::memory_order_release);
|
||||
wake_.notify_all();
|
||||
accepting_.store(false, std::memory_order_relaxed);
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
while (!queue_.empty()) queue_.pop();
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
// Register a callback fired when the queue transitions empty→non-empty.
|
||||
void set_push_callback(std::function<void()> cb) {
|
||||
push_callback_ = std::move(cb);
|
||||
}
|
||||
|
||||
// Ring occupancy, derived lazily from indices — no separate counter on the
|
||||
// hot path. Excludes any out-of-band sentinel (that lives outside the ring).
|
||||
std::size_t size() const {
|
||||
return tail_.load(std::memory_order_relaxed)
|
||||
- head_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// A pending out-of-band sentinel (EOF) counts as consumable work here even
|
||||
// though it holds no ring slot. This is what node readiness checks call, so
|
||||
// a channel carrying only a sentinel still schedules its consumer's next
|
||||
// fire — without this the sentinel would never be popped and the pipeline
|
||||
// would deadlock at teardown.
|
||||
std::size_t approx_size() const {
|
||||
return size() + (has_eof_.load(std::memory_order_acquire) ? 1u : 0u);
|
||||
std::lock_guard lock(mutex_);
|
||||
return queue_.size();
|
||||
}
|
||||
|
||||
std::size_t capacity() const { return capacity_; }
|
||||
@ -278,19 +135,17 @@ public:
|
||||
const ChannelStats& stats() const { return stats_; }
|
||||
|
||||
ChannelSnapshot snapshot(const std::string& name) const {
|
||||
const std::size_t t = tail_.load(std::memory_order_relaxed);
|
||||
const std::size_t h = head_.load(std::memory_order_relaxed);
|
||||
std::lock_guard lock(mutex_);
|
||||
return {
|
||||
name,
|
||||
capacity_,
|
||||
t - h,
|
||||
queue_.size(),
|
||||
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),
|
||||
sizeof(T), // payload bytes — sizeof(T) regardless of storage policy
|
||||
};
|
||||
}
|
||||
|
||||
@ -309,38 +164,12 @@ private:
|
||||
return *s;
|
||||
}
|
||||
|
||||
// Consume the out-of-band sentinel if one is pending. Consumer-only.
|
||||
// Called only when the ring is observed empty, so the sentinel is always
|
||||
// delivered after every value pushed before it.
|
||||
bool take_sentinel(T& out) {
|
||||
if (!has_eof_.load(std::memory_order_acquire)) return false;
|
||||
out = extract(std::move(eof_value_));
|
||||
has_eof_.store(false, std::memory_order_release);
|
||||
stats_.record_pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
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_;
|
||||
|
||||
// Out-of-band sentinel (EOF): stored outside the ring so its delivery never
|
||||
// depends on ring capacity and never blocks the producer. Written by the
|
||||
// producer (push_sentinel), read+cleared by the consumer (take_sentinel);
|
||||
// has_eof_ is the publish/consume handshake.
|
||||
storage_type eof_value_{};
|
||||
std::atomic<bool> has_eof_{false};
|
||||
|
||||
// Separate cache lines: head_ is written only by the consumer;
|
||||
// tail_ and wake_ are written only by the producer.
|
||||
// wake_ wakes a blocked pop() on enqueue or on a pending sentinel.
|
||||
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::queue<storage_type> queue_;
|
||||
std::atomic<bool> accepting_{true};
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
ChannelStats stats_;
|
||||
};
|
||||
|
||||
// ── Channel probe — type-erased snapshot accessor ─────────────────────────────
|
||||
|
||||
@ -1,456 +0,0 @@
|
||||
#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
|
||||
@ -15,7 +15,6 @@ 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};
|
||||
@ -25,9 +24,8 @@ struct ChannelStats {
|
||||
ChannelStats(const ChannelStats&) = delete;
|
||||
ChannelStats& operator=(const ChannelStats&) = delete;
|
||||
|
||||
void record_push(std::size_t current_fill, std::size_t data_bytes) {
|
||||
void record_push(std::size_t current_fill) {
|
||||
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,
|
||||
@ -56,12 +54,6 @@ 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;
|
||||
@ -79,11 +71,6 @@ 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);
|
||||
@ -118,11 +105,10 @@ 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) — nominal struct size, not necessarily data size
|
||||
std::size_t item_bytes; // sizeof(T) for the stored type — set by Channel<T>
|
||||
|
||||
double fill_pct() const {
|
||||
return capacity ? 100.0 * current_fill / capacity : 0.0;
|
||||
@ -130,10 +116,10 @@ struct ChannelSnapshot {
|
||||
double peak_pct() const {
|
||||
return capacity ? 100.0 * peak_fill / capacity : 0.0;
|
||||
}
|
||||
// Bandwidth in MB/s: actual bytes transferred / elapsed seconds
|
||||
// Bandwidth in MB/s: bytes transferred / elapsed seconds
|
||||
double bandwidth_mbs(double elapsed_s) const {
|
||||
if (elapsed_s <= 0.0) return 0.0;
|
||||
return static_cast<double>(bytes_pushed) / elapsed_s / 1e6;
|
||||
if (elapsed_s <= 0.0 || item_bytes == 0) return 0.0;
|
||||
return static_cast<double>(pushes * item_bytes) / elapsed_s / 1e6;
|
||||
}
|
||||
};
|
||||
|
||||
@ -142,52 +128,10 @@ struct NodeSnapshot {
|
||||
uint64_t frames_processed;
|
||||
double ema_exec_ms;
|
||||
double max_exec_ms;
|
||||
double total_blocked_ms; // ThreadPerNode: time blocked in channel pop
|
||||
double total_blocked_ms;
|
||||
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 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
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "node.hpp"
|
||||
#include "port.hpp"
|
||||
#include "traits.hpp"
|
||||
|
||||
@ -15,6 +15,24 @@
|
||||
|
||||
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
|
||||
@ -30,7 +48,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 = repeat_tuple_t<T, N>;
|
||||
using return_tuple = detail::repeat_tuple_t<T, N>;
|
||||
using return_raw = return_tuple;
|
||||
|
||||
static constexpr std::size_t input_count = 1;
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
#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
|
||||
@ -1,279 +0,0 @@
|
||||
#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
|
||||
@ -5,15 +5,8 @@
|
||||
#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"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "fixed_string.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "node.hpp" // INode
|
||||
#include "port.hpp"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
#include "diagnostics.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "node.hpp"
|
||||
#include "port.hpp"
|
||||
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
@ -44,9 +44,6 @@ 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@ -114,19 +111,6 @@ 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;
|
||||
}
|
||||
|
||||
@ -142,17 +126,14 @@ public:
|
||||
web_debug_port_,
|
||||
[this]() {
|
||||
auto s = collect_snapshots();
|
||||
return web_debug::to_json(s.nodes, s.channels, {}, s.elapsed_s, s.pools);
|
||||
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";
|
||||
#endif
|
||||
}
|
||||
|
||||
void stop() override { halt(); }
|
||||
|
||||
// halt(): immediate stop — broadcasts disable to all channels and joins threads.
|
||||
void halt() override {
|
||||
void stop() override {
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_) web_server_->stop();
|
||||
#endif
|
||||
@ -161,37 +142,6 @@ 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 {}
|
||||
|
||||
@ -212,11 +162,6 @@ public:
|
||||
|
||||
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; }
|
||||
@ -226,7 +171,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.pools, s.elapsed_s);
|
||||
os << format_report(s.nodes, s.channels, s.elapsed_s);
|
||||
}
|
||||
|
||||
private:
|
||||
@ -235,7 +180,6 @@ private:
|
||||
struct Snapshots {
|
||||
std::vector<NodeSnapshot> nodes;
|
||||
std::vector<ChannelSnapshot> channels;
|
||||
std::vector<PoolSnapshot> pools;
|
||||
double elapsed_s;
|
||||
};
|
||||
|
||||
@ -251,16 +195,11 @@ private:
|
||||
for (auto& probe : channel_probes_)
|
||||
channels.push_back(probe->snapshot());
|
||||
|
||||
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};
|
||||
return {std::move(nodes), std::move(channels), 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);
|
||||
@ -319,31 +258,6 @@ 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(),
|
||||
@ -357,31 +271,6 @@ 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) {
|
||||
@ -403,33 +292,16 @@ 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.pools, s.elapsed_s);
|
||||
std::cerr << format_report(s.nodes, s.channels, 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();
|
||||
@ -444,10 +316,8 @@ 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_;
|
||||
|
||||
@ -1,29 +1,41 @@
|
||||
#pragma once
|
||||
#include "inode.hpp"
|
||||
#include "pool_node.hpp" // PoolNode, PoolObjectNode
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "fixed_string.hpp"
|
||||
#include "port.hpp"
|
||||
#include "traits.hpp"
|
||||
|
||||
// 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.
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
namespace detail {
|
||||
// ── INode — type-erased interface for Network / watchdog ─────────────────────
|
||||
|
||||
// Private base initialized before PoolNode so its pool can be passed to the
|
||||
// PoolNode constructor (C++ initialises bases left-to-right).
|
||||
struct NodePrivatePool {
|
||||
std::shared_ptr<ThreadPool> pool{std::make_shared<ThreadPool>(1)};
|
||||
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;
|
||||
};
|
||||
|
||||
} // 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<>,
|
||||
@ -32,25 +44,259 @@ 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>
|
||||
: private detail::NodePrivatePool
|
||||
, public PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
|
||||
using Base = PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
|
||||
class Node<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>;
|
||||
|
||||
// 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)
|
||||
: detail::NodePrivatePool{}
|
||||
, Base(pool, fifo_capacity)
|
||||
{}
|
||||
: fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
init_input_channels(std::make_index_sequence<input_count>{});
|
||||
}
|
||||
|
||||
~Node() override { stop(); }
|
||||
|
||||
void start() override { pool->start(); Base::start(); }
|
||||
void stop() override { Base::stop(); pool->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_;
|
||||
};
|
||||
|
||||
// ── ObjectNode ────────────────────────────────────────────────────────────────
|
||||
// ── 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);
|
||||
|
||||
template<typename Obj,
|
||||
typename InputTag = in<>,
|
||||
@ -61,20 +307,222 @@ 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>
|
||||
: private detail::NodePrivatePool
|
||||
, public PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
|
||||
using Base = PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
|
||||
class ObjectNode<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_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)
|
||||
: detail::NodePrivatePool{}
|
||||
, Base(obj, pool, fifo_capacity)
|
||||
{}
|
||||
: obj_(obj), fifo_capacity_(fifo_capacity)
|
||||
{
|
||||
init_input_channels(std::make_index_sequence<input_count>{});
|
||||
}
|
||||
|
||||
~ObjectNode() override { stop(); }
|
||||
|
||||
void start() override { pool->start(); Base::start(); }
|
||||
void stop() override { Base::stop(); pool->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_;
|
||||
};
|
||||
|
||||
// ── make_node overloads for callable objects ──────────────────────────────────
|
||||
@ -100,24 +548,35 @@ 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) {
|
||||
|
||||
@ -1,789 +0,0 @@
|
||||
#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 {
|
||||
|
||||
// ── Sentinel detection ────────────────────────────────────────────────────────
|
||||
// A value is a "sentinel" (must-deliver control token, e.g. EOF) if its type
|
||||
// carries a bool-convertible eof flag — either directly (`v.eof`, as on a raw
|
||||
// source Frame) or nested one level under a `.source` member (`v.source.eof`,
|
||||
// as on the pipeline's SceneFrame/…/MatchedSceneFrame message types, which wrap
|
||||
// the originating Frame). Sentinels are delivered losslessly and non-blockingly
|
||||
// via Channel::push_sentinel() instead of the throwing push(), so backpressure
|
||||
// can never drop the token that unblocks downstream teardown.
|
||||
//
|
||||
// Types with neither shape are never treated as sentinels — both traits are
|
||||
// SFINAE-safe and the runtime check compiles away to `false` for them, so this
|
||||
// stays a no-op for pipelines that don't use an eof convention.
|
||||
template<typename T, typename = void>
|
||||
struct has_eof_field : std::false_type {};
|
||||
template<typename T>
|
||||
struct has_eof_field<T, std::void_t<decltype(static_cast<bool>(std::declval<const T&>().eof))>>
|
||||
: std::true_type {};
|
||||
|
||||
template<typename T, typename = void>
|
||||
struct has_source_eof_field : std::false_type {};
|
||||
template<typename T>
|
||||
struct has_source_eof_field<T,
|
||||
std::void_t<decltype(static_cast<bool>(std::declval<const T&>().source.eof))>>
|
||||
: std::true_type {};
|
||||
|
||||
template<typename T>
|
||||
constexpr bool is_sentinel_value(const T& v) {
|
||||
if constexpr (has_eof_field<T>::value) return static_cast<bool>(v.eof);
|
||||
else if constexpr (has_source_eof_field<T>::value) return static_cast<bool>(v.source.eof);
|
||||
else return false;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
// Sentinels (EOF) must never be dropped: a lost token wedges every
|
||||
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
|
||||
// which never overflows and never blocks this node's worker thread.
|
||||
if (is_sentinel_value(val)) {
|
||||
ch->push_sentinel(std::move(val));
|
||||
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;
|
||||
// Sentinels (EOF) must never be dropped: a lost token wedges every
|
||||
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
|
||||
// which never overflows and never blocks this node's worker thread.
|
||||
if (is_sentinel_value(val)) {
|
||||
ch->push_sentinel(std::move(val));
|
||||
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
|
||||
@ -1,311 +0,0 @@
|
||||
#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", nb::type_slots(network_type_slots<Variant>()))
|
||||
.def("__init__", [](Net* self) {
|
||||
new (self) Net();
|
||||
register_all_converters<Registry>(*self);
|
||||
})
|
||||
// add(name, c++_node)
|
||||
.def("add", [](Net& self, std::string name,
|
||||
std::shared_ptr<IVariantNode<Variant>> node) {
|
||||
self.add(std::move(name), std::move(node));
|
||||
}, nb::arg("name"), nb::arg("node"))
|
||||
// add_node(name, callable, inputs=[...], outputs=[...], capacity=5)
|
||||
.def("add_node", &Net::add_node_python,
|
||||
nb::arg("name"),
|
||||
nb::arg("callable"),
|
||||
nb::arg("inputs") = std::vector<std::string>{},
|
||||
nb::arg("outputs") = std::vector<std::string>{},
|
||||
nb::arg("capacity") = std::size_t(5))
|
||||
.def("connect", &Net::connect,
|
||||
nb::arg("src"), nb::arg("out_idx"),
|
||||
nb::arg("dst"), nb::arg("in_idx"))
|
||||
.def("build", &Net::build)
|
||||
.def("start", &Net::start)
|
||||
.def("stop", &Net::stop)
|
||||
.def("read", &Net::read,
|
||||
nb::arg("node"), nb::arg("out_idx") = std::size_t(0))
|
||||
.def("write", &Net::write,
|
||||
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"))
|
||||
;
|
||||
|
||||
detail::register_entries_impl<Variant>(m, static_cast<Entries*>(nullptr));
|
||||
}
|
||||
|
||||
// ── bind_debug<Registry> ─────────────────────────────────────────────────────
|
||||
// Exposes each node's raw C++ function as a free Python callable so node logic
|
||||
// can be unit-tested without constructing a network.
|
||||
//
|
||||
// Example: assert kpn.double_it(5) == 10
|
||||
|
||||
namespace detail {
|
||||
|
||||
template<typename E>
|
||||
void bind_one_debug(nb::module_& m) {
|
||||
auto name_str = std::string(E::name.view());
|
||||
m.def(name_str.c_str(), E::func);
|
||||
}
|
||||
|
||||
template<typename... Es>
|
||||
void bind_debug_impl(nb::module_& m, std::tuple<Es...>*) {
|
||||
(bind_one_debug<Es>(m), ...);
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
template<typename Registry>
|
||||
void bind_debug(nb::module_& m) {
|
||||
using Entries = typename Registry::entries_tuple;
|
||||
detail::bind_debug_impl(m, static_cast<Entries*>(nullptr));
|
||||
}
|
||||
|
||||
} // namespace kpn::python
|
||||
|
||||
#endif // KPN_BUILD_PYTHON
|
||||
@ -26,25 +26,12 @@ 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:
|
||||
using VNode = IVariantNode<Variant>;
|
||||
using VChannel = IVariantChannel<Variant>;
|
||||
|
||||
// ── GC support ────────────────────────────────────────────────────────────
|
||||
// Visit every Python object this network transitively holds (currently the
|
||||
// callable of each PyNode). Used by the Network type's tp_traverse slot so
|
||||
// Python's cyclic GC can discover instance → callable → globals() cycles.
|
||||
// Defined out-of-line below, once PyNode is a complete type.
|
||||
template<typename Fn>
|
||||
void visit_python_objects(Fn&& visit) const;
|
||||
// Drop all Python references held by nodes, breaking any cycle (tp_clear).
|
||||
void clear_python_objects();
|
||||
|
||||
// ── Builder API ───────────────────────────────────────────────────────────
|
||||
|
||||
void add(std::string name, std::shared_ptr<VNode> node) {
|
||||
@ -56,6 +43,8 @@ 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)
|
||||
{
|
||||
@ -76,6 +65,7 @@ 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);
|
||||
@ -102,12 +92,16 @@ 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);
|
||||
@ -120,6 +114,7 @@ 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())
|
||||
@ -133,31 +128,8 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 ───────────────────────────────────────────
|
||||
// ── Converter registration ────────────────────────────────────────────────
|
||||
// Called once per type at module init time to register to/from Python converters.
|
||||
|
||||
template<typename T>
|
||||
void register_type(
|
||||
@ -171,52 +143,6 @@ 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);
|
||||
@ -240,14 +166,15 @@ private:
|
||||
return node + ":" + std::to_string(idx);
|
||||
}
|
||||
|
||||
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())
|
||||
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())
|
||||
throw std::runtime_error(
|
||||
"no channel factory for type: " + std::string(type.name()) +
|
||||
" — call register_full_type<T>() or register_tap_factory<T>()");
|
||||
return it->second(cap);
|
||||
"no tap factory for type: " + std::string(type.name()) +
|
||||
" — was register_type() called for this type?");
|
||||
return it->second();
|
||||
}
|
||||
|
||||
nb::object variant_to_python(Variant v) {
|
||||
@ -267,6 +194,18 @@ 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_;
|
||||
@ -274,27 +213,19 @@ 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_;
|
||||
|
||||
// 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_;
|
||||
std::map<std::type_index, std::function<std::shared_ptr<VChannel>()>> tap_factories_;
|
||||
};
|
||||
|
||||
// ── PyNode<Variant> ───────────────────────────────────────────────────────────
|
||||
// A pure-Python processing node. Holds a nanobind callable.
|
||||
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs.
|
||||
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs (release GIL).
|
||||
|
||||
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,
|
||||
@ -333,6 +264,7 @@ 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();
|
||||
}
|
||||
@ -377,20 +309,14 @@ public:
|
||||
out_channels_[i] = std::move(ch);
|
||||
}
|
||||
|
||||
// ── GC support (tp_traverse / tp_clear on the owning Network) ──────────────
|
||||
// The node holds a Python callable, which typically forms an
|
||||
// instance → callable → globals() → instance cycle. Expose the callable so
|
||||
// the Network's GC slots can traverse and clear it. See bindings.hpp's
|
||||
// network_tp_traverse/network_tp_clear.
|
||||
const nb::object& python_callable() const { return callable_; }
|
||||
void clear_python_callable() { callable_ = nb::object(); }
|
||||
|
||||
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();
|
||||
@ -398,6 +324,7 @@ private:
|
||||
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;
|
||||
@ -420,6 +347,7 @@ private:
|
||||
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]));
|
||||
@ -457,69 +385,15 @@ private:
|
||||
NodeStats stats_;
|
||||
};
|
||||
|
||||
// ── PyNetwork GC helpers (defined here: PyNode is now complete) ────────────────
|
||||
|
||||
template<typename Variant>
|
||||
template<typename Fn>
|
||||
void PyNetwork<Variant>::visit_python_objects(Fn&& visit) const {
|
||||
for (const auto& [name, node] : nodes_)
|
||||
if (auto* py = dynamic_cast<const PyNode<Variant>*>(node.get()))
|
||||
visit(py->python_callable());
|
||||
}
|
||||
|
||||
template<typename Variant>
|
||||
void PyNetwork<Variant>::clear_python_objects() {
|
||||
for (auto& [name, node] : nodes_)
|
||||
if (auto* py = dynamic_cast<PyNode<Variant>*>(node.get()))
|
||||
py->clear_python_callable();
|
||||
}
|
||||
|
||||
// ── GC type slots for the Network binding ─────────────────────────────────────
|
||||
// The Network holds Python callables (via PyNode), forming uncollectable
|
||||
// instance → callable → globals() → instance cycles at interpreter shutdown.
|
||||
// These slots let Python's cyclic collector traverse and break them, silencing
|
||||
// nanobind's leak warnings. See the nanobind "Reference leaks" documentation.
|
||||
|
||||
template<typename Variant>
|
||||
int network_tp_traverse(PyObject* self, visitproc visit, void* arg) {
|
||||
Py_VISIT(Py_TYPE(self));
|
||||
if (!nb::inst_ready(self))
|
||||
return 0;
|
||||
auto* net = nb::inst_ptr<PyNetwork<Variant>>(self);
|
||||
int rv = 0;
|
||||
net->visit_python_objects([&](const nb::object& obj) {
|
||||
if (rv == 0 && obj.is_valid())
|
||||
rv = visit(obj.ptr(), arg);
|
||||
});
|
||||
return rv;
|
||||
}
|
||||
|
||||
template<typename Variant>
|
||||
int network_tp_clear(PyObject* self) {
|
||||
auto* net = nb::inst_ptr<PyNetwork<Variant>>(self);
|
||||
net->clear_python_objects();
|
||||
return 0;
|
||||
}
|
||||
|
||||
template<typename Variant>
|
||||
PyType_Slot* network_type_slots() {
|
||||
static PyType_Slot slots[] = {
|
||||
{ Py_tp_traverse, reinterpret_cast<void*>(&network_tp_traverse<Variant>) },
|
||||
{ Py_tp_clear, reinterpret_cast<void*>(&network_tp_clear<Variant>) },
|
||||
{ 0, nullptr }
|
||||
};
|
||||
return slots;
|
||||
}
|
||||
|
||||
// ── register_py_network (legacy helper) ───────────────────────────────────────
|
||||
// Registers PyNetwork<Variant> with the given nanobind module.
|
||||
// Prefer bind_network<Registry> from auto_bind.hpp for new code.
|
||||
// ── 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.
|
||||
|
||||
template<typename Variant>
|
||||
void register_py_network(nb::module_& m, const char* class_name = "Network") {
|
||||
using Net = PyNetwork<Variant>;
|
||||
|
||||
nb::class_<Net>(m, class_name, nb::type_slots(network_type_slots<Variant>()))
|
||||
nb::class_<Net>(m, class_name)
|
||||
.def(nb::init<>())
|
||||
.def("connect", &Net::connect,
|
||||
nb::arg("src"), nb::arg("out_idx"),
|
||||
@ -528,7 +402,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") = std::size_t(0))
|
||||
nb::arg("node"), nb::arg("out_idx") = 0)
|
||||
.def("write", &Net::write,
|
||||
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
|
||||
}
|
||||
|
||||
@ -1,211 +0,0 @@
|
||||
#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
|
||||
@ -1,190 +0,0 @@
|
||||
#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
|
||||
@ -2,7 +2,7 @@
|
||||
#include "channel.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "fanout.hpp"
|
||||
#include "inode.hpp"
|
||||
#include "node.hpp"
|
||||
#include "port.hpp"
|
||||
#include "tmp/fanout_groups.hpp"
|
||||
#include "tmp/topo_sort.hpp"
|
||||
@ -13,9 +13,7 @@
|
||||
#endif
|
||||
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
@ -76,10 +74,6 @@ 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 {
|
||||
@ -112,35 +106,21 @@ 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
|
||||
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);
|
||||
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";
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void stop() override { halt(); }
|
||||
|
||||
void halt() override {
|
||||
void stop() override {
|
||||
stop_flag_ = true;
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_) web_server_->stop();
|
||||
@ -151,22 +131,6 @@ 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); }
|
||||
@ -175,36 +139,10 @@ 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";
|
||||
@ -223,8 +161,6 @@ private:
|
||||
struct Snapshots {
|
||||
std::vector<NodeSnapshot> nodes;
|
||||
std::vector<ChannelSnapshot> channels;
|
||||
std::vector<ResourceSnapshot> resources;
|
||||
std::vector<PoolSnapshot> pools;
|
||||
double elapsed_s;
|
||||
};
|
||||
|
||||
@ -242,27 +178,7 @@ private:
|
||||
for (auto& probe : channel_probes_)
|
||||
channels.push_back(probe->snapshot());
|
||||
|
||||
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));
|
||||
}
|
||||
return {std::move(nodes), std::move(channels), elapsed_s};
|
||||
}
|
||||
|
||||
std::string name_;
|
||||
@ -273,13 +189,9 @@ 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
|
||||
};
|
||||
@ -317,16 +229,12 @@ 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(sname);
|
||||
s->set_name(sname);
|
||||
user_node_names.push_back(node_display_name<SrcT>());
|
||||
}
|
||||
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(dname);
|
||||
d->set_name(dname);
|
||||
user_node_names.push_back(node_display_name<DstT>());
|
||||
}
|
||||
};
|
||||
(collect(edges), ...);
|
||||
@ -409,10 +317,8 @@ 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(fname);
|
||||
fanout_node_names.push_back(node_name.template operator()<NodeT>());
|
||||
}(fn), ...);
|
||||
}, *fanout_storage);
|
||||
|
||||
|
||||
@ -83,18 +83,4 @@ template<typename F>
|
||||
inline constexpr std::size_t output_count_v =
|
||||
std::tuple_size_v<normalised_return_t<return_t<F>>>;
|
||||
|
||||
// ── repeat_tuple: std::tuple<T, T, ..., T> with N repetitions ────────────────
|
||||
|
||||
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
|
||||
struct repeat_tuple;
|
||||
|
||||
template<typename T, std::size_t N, std::size_t... Is>
|
||||
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
|
||||
template<std::size_t> using always_T = T;
|
||||
using type = std::tuple<always_T<Is>...>;
|
||||
};
|
||||
|
||||
template<typename T, std::size_t N>
|
||||
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
@ -239,13 +239,11 @@ 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 {};
|
||||
struct PythonConverter {
|
||||
static_assert(sizeof(T) == 0,
|
||||
"PythonConverter<T> must be specialised for each type used in a PyNetwork");
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
@ -46,9 +46,7 @@ static std::pair<std::string,std::string> parse_edge_name(const std::string& nam
|
||||
|
||||
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
|
||||
const std::vector<ChannelSnapshot>& channels,
|
||||
const std::vector<ResourceSnapshot>& resources = {},
|
||||
double elapsed_s = 0.0,
|
||||
const std::vector<PoolSnapshot>& pools = {}) {
|
||||
double elapsed_s = 0.0) {
|
||||
std::ostringstream o;
|
||||
o << std::fixed;
|
||||
o.precision(2);
|
||||
@ -86,32 +84,6 @@ 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();
|
||||
}
|
||||
@ -140,24 +112,12 @@ 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;
|
||||
@ -179,7 +139,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, edgeLabelBwSel;
|
||||
let sim, linkSel, nodeSel, labelSel, edgeLabelSel;
|
||||
let nodes = [], links = [];
|
||||
|
||||
function edgeColor(fill_pct) {
|
||||
@ -226,10 +186,6 @@ 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')
|
||||
@ -244,7 +200,6 @@ 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');
|
||||
@ -269,25 +224,7 @@ 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 => {
|
||||
@ -315,7 +252,6 @@ 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() {
|
||||
@ -342,10 +278,6 @@ 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})`);
|
||||
}
|
||||
|
||||
@ -379,14 +311,12 @@ class WebDebugServer {
|
||||
public:
|
||||
using SnapshotFn = std::function<std::string()>;
|
||||
|
||||
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) {}
|
||||
explicit WebDebugServer(uint16_t port, SnapshotFn fn)
|
||||
: port_(port), snapshot_fn_(std::move(fn)) {}
|
||||
|
||||
void start() {
|
||||
svr_.Get("/", [this](const httplib::Request&, httplib::Response& res) {
|
||||
res.set_content(html_, "text/html");
|
||||
svr_.Get("/", [](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");
|
||||
@ -409,7 +339,6 @@ public:
|
||||
private:
|
||||
uint16_t port_;
|
||||
SnapshotFn snapshot_fn_;
|
||||
const char* html_;
|
||||
httplib::Server svr_;
|
||||
std::thread thread_;
|
||||
};
|
||||
|
||||
50
mkdocs.yml
50
mkdocs.yml
@ -1,50 +0,0 @@
|
||||
site_name: KPN++
|
||||
site_description: A C++20 Kahn Process Network library
|
||||
repo_url: https://gitea.tourolle.paris/dtourolle/KPN
|
||||
repo_name: dtourolle/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
|
||||
@ -1,39 +1,164 @@
|
||||
#define KPN_BUILD_PYTHON
|
||||
#include <kpn/python/auto_bind.hpp>
|
||||
#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 <iostream>
|
||||
#include <map>
|
||||
|
||||
namespace nb = nanobind;
|
||||
using namespace kpn;
|
||||
using namespace kpn::python;
|
||||
|
||||
// ── Demo node functions (hello-pipeline) ──────────────────────────────────────
|
||||
// ── Node functions for the hello-pipeline examples ────────────────────────────
|
||||
|
||||
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'; }
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
// Variant type is auto-deduced as std::variant<int> from the port types above.
|
||||
// ── Variant type ──────────────────────────────────────────────────────────────
|
||||
// Deduplicated port types across all registered node functions.
|
||||
// produce: () → int | double_it: int → int | print_it: int → void
|
||||
// Unique types: int
|
||||
|
||||
using DemoNodes = NodeRegistry<
|
||||
Entry<produce, "produce">,
|
||||
Entry<double_it, "double_it">,
|
||||
Entry<print_it, "print_it">
|
||||
>;
|
||||
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>;
|
||||
|
||||
// ── Module ────────────────────────────────────────────────────────────────────
|
||||
// ── 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>();
|
||||
}
|
||||
|
||||
NB_MODULE(kpn_python, m) {
|
||||
m.doc() = "KPN++ Python bindings — Kahn Process Network library";
|
||||
|
||||
// Registers: Network, INode, ProduceNode, DoubleItNode, PrintItNode,
|
||||
// make_produce(), make_double_it(), make_print_it()
|
||||
bind_network<DemoNodes>(m);
|
||||
// ── IVariantNode base ─────────────────────────────────────────────────────
|
||||
nb::class_<IVariantNode<KpnVariant>>(m, "INode");
|
||||
|
||||
// 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);
|
||||
// ── 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"));
|
||||
}
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
#!/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):
|
||||
// --8<-- [start:snippet_name]
|
||||
...content...
|
||||
// --8<-- [end: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} --8<-- [start:{name}]'
|
||||
close_tag = f'{prefix} --8<-- [end:{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,4 +1,4 @@
|
||||
// network.cpp — orchestrator/watchdog implementation details. (CI: pipeline trigger)
|
||||
// network.cpp — orchestrator/watchdog implementation details.
|
||||
// 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.
|
||||
|
||||
@ -32,9 +32,6 @@ 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
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <kpn/channel.hpp>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
using namespace kpn;
|
||||
|
||||
@ -72,21 +70,13 @@ TEST_CASE("push to disabled channel is silently dropped", "[channel]") {
|
||||
REQUIRE(ch.size() == 0);
|
||||
}
|
||||
|
||||
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)
|
||||
TEST_CASE("disable clears existing queue contents", "[channel]") {
|
||||
Channel<int> ch(5);
|
||||
ch.push(1);
|
||||
ch.push(2);
|
||||
REQUIRE(ch.size() == 2);
|
||||
ch.disable();
|
||||
// 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);
|
||||
REQUIRE(ch.size() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("enable re-accepts pushes after disable", "[channel]") {
|
||||
@ -107,134 +97,3 @@ 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);
|
||||
}
|
||||
|
||||
TEST_CASE("push_sentinel never overflows even on a full channel", "[channel][sentinel]") {
|
||||
Channel<int> ch(2);
|
||||
ch.push(1);
|
||||
ch.push(2); // channel full — a plain push(3) would throw ChannelOverflowError
|
||||
|
||||
// The sentinel is stored out-of-band, so it neither throws nor blocks the
|
||||
// caller — the exact property an EOF token needs under backpressure. This
|
||||
// returns immediately with the ring still full.
|
||||
REQUIRE(ch.push_sentinel(99));
|
||||
REQUIRE(ch.size() == 2); // sentinel did not consume ring capacity
|
||||
}
|
||||
|
||||
TEST_CASE("push_sentinel is delivered after all ring data, in order", "[channel][sentinel]") {
|
||||
Channel<int> ch(4);
|
||||
ch.push(1);
|
||||
ch.push(2);
|
||||
ch.push_sentinel(99); // enqueue EOF while data is still buffered
|
||||
|
||||
// Data drains first; the sentinel arrives only once the ring is empty.
|
||||
REQUIRE(ch.pop() == 1);
|
||||
REQUIRE(ch.pop() == 2);
|
||||
REQUIRE(ch.pop() == 99);
|
||||
}
|
||||
|
||||
TEST_CASE("push_sentinel wakes a blocked pop", "[channel][sentinel]") {
|
||||
Channel<int> ch(2); // empty
|
||||
std::thread producer([&] {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
ch.push_sentinel(99); // must wake a consumer parked on an empty ring
|
||||
});
|
||||
REQUIRE(ch.pop() == 99);
|
||||
producer.join();
|
||||
}
|
||||
|
||||
TEST_CASE("approx_size counts a pending sentinel so consumers stay schedulable",
|
||||
"[channel][sentinel]") {
|
||||
Channel<int> ch(4);
|
||||
REQUIRE(ch.approx_size() == 0);
|
||||
ch.push_sentinel(99);
|
||||
// Node readiness checks call approx_size(); it must report the out-of-band
|
||||
// sentinel as consumable work even though it holds no ring slot.
|
||||
REQUIRE(ch.approx_size() == 1);
|
||||
REQUIRE(ch.size() == 0); // ...but the ring itself is still empty
|
||||
int out = 0;
|
||||
REQUIRE(ch.try_pop_now(out));
|
||||
REQUIRE(out == 99);
|
||||
REQUIRE(ch.approx_size() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("try_pop_now delivers a pending sentinel once the ring is empty",
|
||||
"[channel][sentinel]") {
|
||||
Channel<int> ch(2);
|
||||
ch.push(1);
|
||||
ch.push_sentinel(99);
|
||||
|
||||
int out = 0;
|
||||
REQUIRE(ch.try_pop_now(out)); // ring data first
|
||||
REQUIRE(out == 1);
|
||||
REQUIRE(ch.try_pop_now(out)); // then the sentinel
|
||||
REQUIRE(out == 99);
|
||||
REQUIRE_FALSE(ch.try_pop_now(out)); // nothing left
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
#include <kpn/node.hpp>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
using namespace kpn;
|
||||
|
||||
@ -48,104 +47,3 @@ 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());
|
||||
}
|
||||
|
||||
@ -1,464 +0,0 @@
|
||||
#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);
|
||||
}
|
||||
@ -1,229 +0,0 @@
|
||||
#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();
|
||||
}
|
||||
@ -1,239 +0,0 @@
|
||||
#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");
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user