Duncan Tourolle 7cb92a4091
All checks were successful
🧪 Test / test (push) Successful in 4m41s
Build docs with a pre-configured docker
2026-06-20 08:55:48 +02:00
2026-05-08 17:48:16 +02:00
2026-05-08 20:00:15 +02:00

KPN++

A C++20 Kahn Process Network (KPN) library. Each node wraps a function and runs in its own thread, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind.


Requirements

Dependency Version Notes
CMake ≥ 3.21
C++ compiler GCC ≥ 11, Clang ≥ 13, MSVC 19.29 C++20 required
Threads system find_package(Threads)
nanobind ≥ 2.1 auto-fetched if not installed; Python ≥ 3.8
Catch2 v3 auto-fetched for tests
Google Test v1.14 auto-fetched for tests
OpenCV ≥ 4 optional; only for example 09

Build

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):

cmake -B build -DKPN_BUILD_PYTHON=ON
cmake --build build --parallel

Disable examples:

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<...>.

#include <kpn/kpn.hpp>
using namespace kpn;

Source, transform, and sink — from examples/01_hello_pipeline/main.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 node returning a tuple — from examples/03_multi_output/main.cpp:

// Multi-output: returns (key, value) as a tuple — KPN++ routes each element
// to its own output port automatically.
static std::tuple<std::string, std::string> parse(std::string kv) {
    auto sep = kv.find('=');
    if (sep == std::string::npos) return {kv, ""};
    return {kv.substr(0, sep), kv.substr(sep + 1)};
}

Creating Nodes

Index-only ports (from examples/01_hello_pipeline/main.cpp):

auto src  = make_node<produce>(5);
auto dbl  = make_node<double_it>(5);
auto sink = make_node<print_it>(5);

Named ports (from examples/02_named_ports/main.cpp):

// 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
auto snk = make_node<report>(in<"count", "words">{}, 4);

Multi-named output source (from examples/09_opencv_cellshade/main.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:

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.start();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
net.stop();

.build() runs cycle detection — throws NetworkCycleError on cycles.

Named port syntax in template context: when the node variable is auto-deduced, use .template output<"name">() and .template input<"name">() to help the parser.

Channel Semantics

  • 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):

// Override: store Tag by value despite being a struct
// (it's trivially copyable and small — this just makes the policy explicit)
template<>
struct kpn::channel_storage_policy<Tag> {
    static constexpr bool by_value = true;
};

Diagnostics & Error Handling

Custom diagnostics handler — fires on the watchdog interval (from examples/05_error_handling/main.cpp):

// 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():

  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:

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:

// 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:

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:

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.

Source node — returns two frames (colour + grey) as a tuple, routing them to separate downstream branches:

static std::tuple<cv::Mat, cv::Mat> capture() {
    constexpr int W = 640, H = 480;
    static cv::VideoCapture cap;
    static bool opened = false;
    if (!opened) {
        opened = true;
        cap.open(0, cv::CAP_V4L2);
        if (cap.isOpened()) {
            cap.set(cv::CAP_PROP_FRAME_WIDTH,  W);
            cap.set(cv::CAP_PROP_FRAME_HEIGHT, H);
        } else {
            std::cerr << "[capture] no webcam — using synthetic animated pattern\n";
        }
    }

    cv::Mat frame;
    if (cap.isOpened()) {
        auto t0 = std::chrono::steady_clock::now();
        cap >> frame;
        auto elapsed = std::chrono::steady_clock::now() - t0;
        if (elapsed < std::chrono::milliseconds(20))
            std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed);
        if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3);
    } else {
        static int tick = 0;
        static cv::Mat grad = make_gradient(W, H);
        ++tick;
        frame = grad.clone();
        int r = 150 + (tick % 80) * 4;
        cv::circle(frame, {W/2,   H/2},   r,     {255, 200,   0}, -1);
        cv::circle(frame, {W/2,   H/2},   r / 2, {  0, 128, 255}, -1);
        cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200,   0, 200}, -1);
        std::this_thread::sleep_for(std::chrono::milliseconds(33));
    }
    return {frame.clone(), frame.clone()};
}

Full network wiring:

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 — one node fans out to two independent sinks via a tuple return:

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();

Python Bindings

Python bindings are scaffolded but not yet fully implemented. See python/kpn_python.cpp and include/kpn/python/bindings.hpp.

A PyNetwork is constructed from a closed list of C++ node types. The variant of all port types is derived at compile time — no runtime type registration needed.

GIL rules (non-negotiable):

  • Acquire the GIL only for the duration of a Python callable invocation.
  • Release the GIL before any blocking channel operation (pop(), push(), net.read(), net.write()).

Violating the second rule deadlocks.


Examples

Example What it shows
01_hello_pipeline Linear pipeline, index-based port wiring
02_named_ports in<>/out<> name tags, named port access
03_multi_output Tuple-returning node, per-element sub-port routing
04_storage_policy channel_storage_policy default and specialisation
05_error_handling ChannelOverflowError, ErrorHandler
06_watchdog Watchdog interval, stall detection
07_python_network PyNetwork, pure Python node (pending)
08_python_subport net.read, net.write, sub-port tap (pending)
09_opencv_cellshade Real-time cell-shading on webcam/pattern; requires OpenCV ≥ 4

Run the cell-shading example:

./build/examples/09_opencv_cellshade
# Press 'q' or close the window to stop.
# Falls back to an animated synthetic pattern if no webcam is found.

Performance

Measured on Linux (x86-64, -O3 -march=native) with benchmarks/bench_pipeline. Each topology pushes N items through the graph; overhead_us/item strips out the per-node compute time to isolate framework cost.

Overhead formula: (elapsed (N + depth 1) × work_us) / N removes the expected pipeline-fill cost so the number reflects pure framework latency.

Baseline overhead (private pools, 100 µs/node)

Topology items/sec overhead µs/item
chain depth-1 9 797 ~2
chain depth-4 9 448 ~4
chain depth-8 9 078 ~7
chain depth-16 7 004 ~13 ← oversubscription
chain depth-32 4 179 ~77 ← oversubscription
wide fanout-1 9 751 ~3
wide fanout-4 9 668 ~3
diamond (2×2) 9 607 ~4

Chain overhead is flat at ~27 µs/hop for depths within the machine's core count, then rises once threads compete for CPU. Wide and diamond topologies add no measurable overhead as fanout increases — all branches run in parallel.

Scheduling modes

Node<> gives each node a private ThreadPool(1). PoolNode<> lets multiple nodes share one pool. The right choice depends on the graph shape:

Scenario Recommended
Work per node < 100 µs, deep chain Private pools — lower per-hop latency
Work per node ≥ 100 µs, wide/diamond Shared pool, threads = hardware_concurrency
Any graph, bounded thread count required Shared pool, threads ≥ max parallel nodes

A shared single-thread pool (threads=1) fully serialises the graph — throughput divides by depth for chains and by width for fanout topologies. A shared pool with threads ≥ max_concurrent_nodes matches private-pool throughput while keeping the OS thread count bounded.

vs. TBB flow graph

Benchmarked against tbb::flow::function_node<int,int> (serial concurrency) with tbb::flow::broadcast_node<int> for fanout. Run with cmake -DKPN_BUILD_BENCHMARKS=ON — TBB benchmarks are included automatically when find_package(TBB) succeeds.

Channel implementation: lock-free SPSC ring buffer with std::atomic::wait/notify_one (C++20 portable futex) plus a configurable spin-before-sleep window (default ~4 µs). Large types are stored as shared_ptr<const T> — fanout copies reference counts, not data.

Overhead µs/item at work_us = 10 (framework overhead dominates):

Topology KPN private TBB
chain depth-1 1.7 1.4
chain depth-4 2.5 2.2
chain depth-8 3.0 3.6
chain depth-16 9.3 13.0
chain depth-32 23.2 14.2
wide fanout-4 2.5 1.4
diamond (2×2) 3.4 1.9

Overhead µs/item at work_us = 100 (moderate compute, KPN wins):

Topology KPN private TBB
chain depth-1 2.1 3.5
chain depth-4 4.3 5.2
chain depth-8 6.7 8.5
chain depth-16 12.8 17.4
chain depth-32 77 81
wide fanout-4 3.4 1.9
diamond (2×2) 4.1 6.1

KPN private pools beat TBB for every chain and diamond topology at 100 µs/node, and match TBB within ~20% at 10 µs/node for shallow chains. TBB retains an edge on wide fanout (serial dispatch loop vs. work-stealing pool) and at extreme oversubscription depths (chain-32 at 10 µs). The remaining gap at light work is the cost of atomic::wait vs. TBB's continuously-spinning worker threads.

vs. TBB — API

The function signature is the node. KPN infers input and output types automatically; there is no graph object to manage.

Single-output node:

// 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:

// 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:

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:

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
Description
No description provided
Readme MIT 1.4 MiB
Languages
C++ 97%
CMake 1.8%
Python 0.9%
Ruby 0.3%