Compare commits
55
Commits
79916f1da1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5375ab41b2 | ||
|
|
b500570c47 | ||
|
|
73828bcffe | ||
|
|
a3f61fcb3c | ||
|
|
771b9f8593 | ||
|
|
3b67b7e1e9 | ||
|
|
433c3b3859 | ||
|
|
6802328e97 | ||
|
|
c9aa246322 | ||
|
|
27f884496d | ||
|
|
00245f5760 | ||
|
|
7a3e96cc99 | ||
|
|
80c2b1fb2f | ||
|
|
012b64dd3e | ||
|
|
7b7f631e6d | ||
|
|
97670d8ba3 | ||
|
|
b9698fae60 | ||
|
|
87c5f98d04 | ||
|
|
abbb2d4770 | ||
|
|
0f277c0f98 | ||
|
|
139bfbb794 | ||
|
|
8d319eeb88 | ||
|
|
15e993f6ca | ||
|
|
a5c016833d | ||
|
|
f53af260a2 | ||
|
|
5628447ea8 | ||
|
|
6a4f45f111 | ||
|
|
c73edffe5c | ||
|
|
091211cb19 | ||
|
|
a8cfe7300a | ||
|
|
454f72c167 | ||
|
|
9c5ce5f34a | ||
|
|
28e06675f5 | ||
|
|
6595e6e925 | ||
|
|
75b34f31bb | ||
|
|
4b6e498ba7 | ||
|
|
5ecf3cde4f | ||
|
|
ec19137ed9 | ||
|
|
a0c4bf580e | ||
|
|
3ac2242df1 | ||
|
|
399ee4cf9b | ||
|
|
66feb91821 | ||
|
|
903dd4eea5 | ||
|
|
298c9e770b | ||
|
|
2b0873b61b | ||
|
|
c4538f03ca | ||
|
|
949c8134ef | ||
|
|
19f5a2b0ae | ||
|
|
a4de64ea04 | ||
|
|
7c6a8be2b7 | ||
|
|
4c0f1f6923 | ||
|
|
6b52526e44 | ||
|
|
7cb92a4091 | ||
|
|
20668d6955 | ||
|
|
6f384dc4b5 |
@@ -0,0 +1,87 @@
|
||||
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
|
||||
|
||||
# ThreadSanitizer run for the lock-free Channel<T>. Same trigger conditions as
|
||||
# test (code or image changed); runs in parallel with test.
|
||||
tsan:
|
||||
needs: [changes, docker]
|
||||
if: ${{ !failure() && !cancelled() && (needs.changes.outputs.code == 'true' || needs.changes.outputs.dockerfile == 'true') }}
|
||||
uses: ./.gitea/workflows/tsan.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
|
||||
@@ -0,0 +1,57 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,34 @@
|
||||
name: '📚 Docs'
|
||||
|
||||
# Triggering and path filtering are owned by ci.yaml (the orchestrator), which
|
||||
# calls this as a reusable workflow. workflow_dispatch is kept for manual runs.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # full history needed for mkdocs gh-deploy
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
|
||||
- name: Build and deploy to gitea-pages branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdocs gh-deploy \
|
||||
--force \
|
||||
--remote-branch gitea-pages \
|
||||
--remote-name origin \
|
||||
--message "docs: deploy from ${{ github.sha }}"
|
||||
+15
-16
@@ -1,18 +1,9 @@
|
||||
name: '🧪 Test'
|
||||
|
||||
# Triggering and path filtering are owned by ci.yaml (the orchestrator), which
|
||||
# calls this as a reusable workflow. workflow_dispatch is kept for manual runs.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -41,7 +32,7 @@ jobs:
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DKPN_BUILD_TESTS=ON \
|
||||
-DKPN_BUILD_EXAMPLES=OFF \
|
||||
-DKPN_BUILD_EXAMPLES=ON \
|
||||
-DKPN_BUILD_PYTHON=ON \
|
||||
-DFETCHCONTENT_BASE_DIR=$HOME/.cmake/fetchcontent
|
||||
|
||||
@@ -49,18 +40,26 @@ jobs:
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: cmake --build build --parallel
|
||||
|
||||
- name: Run tests
|
||||
- name: Run unit tests
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: |
|
||||
cd build
|
||||
ctest --output-on-failure --output-junit test-results.xml
|
||||
ctest --output-on-failure --output-junit test-results.xml --label-exclude examples
|
||||
|
||||
- name: Run example smoke tests
|
||||
working-directory: test-${{ github.run_id }}
|
||||
run: |
|
||||
cd build
|
||||
ctest --output-on-failure --output-junit example-results.xml -L examples
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results
|
||||
path: test-${{ github.run_id }}/build/test-results.xml
|
||||
path: |
|
||||
test-${{ github.run_id }}/build/test-results.xml
|
||||
test-${{ github.run_id }}/build/example-results.xml
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
name: '🧵 ThreadSanitizer'
|
||||
|
||||
# Reusable workflow: builds the channel stress suite with ThreadSanitizer and
|
||||
# runs it. This is the dynamic half of verifying the lock-free SPSC Channel<T>
|
||||
# (the static half is the CDSChecker model-check harness in verify/).
|
||||
#
|
||||
# Triggering and path filtering are owned by ci.yaml (the orchestrator), which
|
||||
# calls this only when code changed. workflow_dispatch is kept for manual runs.
|
||||
#
|
||||
# Runs in the prebuilt builder image (gcc:14), which already ships libtsan — no
|
||||
# package installs at job time.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
tsan:
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
|
||||
# This runner is Docker nested in an unprivileged LXC container, whose
|
||||
# kernel randomizes mmap addresses beyond the range TSan's fixed shadow
|
||||
# mapping expects, so TSan aborts at init with "unexpected memory
|
||||
# mapping". The fix is to disable ASLR per-process with `setarch -R`
|
||||
# (below), which needs the personality(2) syscall that Docker's default
|
||||
# seccomp profile blocks. seccomp=unconfined permits it. Verified on the
|
||||
# runner: setarch -R alone gets EPERM, seccomp alone still aborts, both
|
||||
# together run clean. Scoped to this job, which runs only our own tests.
|
||||
options: --security-opt seccomp=unconfined
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: tsan-${{ github.run_id }}
|
||||
|
||||
- name: Cache FetchContent dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cmake/fetchcontent
|
||||
key: cmake-fetchcontent-${{ hashFiles('**/CMakeLists.txt') }}
|
||||
restore-keys: cmake-fetchcontent-
|
||||
|
||||
- name: Configure (TSan)
|
||||
working-directory: tsan-${{ github.run_id }}
|
||||
run: |
|
||||
cmake -S . -B build \
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DKPN_SANITIZER=thread \
|
||||
-DKPN_BUILD_TESTS=ON \
|
||||
-DKPN_BUILD_EXAMPLES=OFF \
|
||||
-DKPN_BUILD_PYTHON=OFF \
|
||||
-DFETCHCONTENT_BASE_DIR=$HOME/.cmake/fetchcontent
|
||||
|
||||
- name: Build (TSan)
|
||||
working-directory: tsan-${{ github.run_id }}
|
||||
run: cmake --build build --parallel --target kpn_tests kpn_tests_stress
|
||||
|
||||
- name: Run stress suite under TSan
|
||||
working-directory: tsan-${{ github.run_id }}
|
||||
# halt_on_error=1 makes the first detected race fail the job; the report
|
||||
# (with both stacks) is printed to the log. second_deadlock_stack gives
|
||||
# the full picture for lock-order issues.
|
||||
env:
|
||||
TSAN_OPTIONS: "halt_on_error=1 second_deadlock_stack=1"
|
||||
# setarch -R disables ASLR for this process; see the container comment.
|
||||
run: setarch -R ./build/tests/kpn_tests_stress
|
||||
|
||||
- name: Run unit tests under TSan
|
||||
working-directory: tsan-${{ github.run_id }}
|
||||
env:
|
||||
TSAN_OPTIONS: "halt_on_error=1 second_deadlock_stack=1"
|
||||
run: setarch -R ./build/tests/kpn_tests
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -rf tsan-${{ github.run_id }}
|
||||
@@ -1,6 +1,8 @@
|
||||
# Build output
|
||||
build/
|
||||
build_test/
|
||||
build_debug/
|
||||
site/
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -23,5 +25,11 @@ venv/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Benchmark output (scripts/bench_repro_check.py --out-dir)
|
||||
bench_runs/
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
include/kpn/ort_cache/
|
||||
build-tsan/
|
||||
build*/
|
||||
|
||||
@@ -10,6 +10,30 @@ option(KPN_BUILD_PYTHON "Build Python bindings (requires nanobind)" ON)
|
||||
option(KPN_BUILD_EXAMPLES "Build examples" ON)
|
||||
option(KPN_WEB_DEBUG "Enable web debug UI (cpp-httplib)" OFF)
|
||||
|
||||
# Sanitizer build. Empty = off. Accepts "thread", "address", "undefined",
|
||||
# or a combination like "address,undefined". Applied to all kpn targets via
|
||||
# the kpn_sanitizer_flags() helper below.
|
||||
#
|
||||
# The lock-free SPSC Channel<T> (include/kpn/channel.hpp) has hand-reasoned
|
||||
# acquire/release ordering; -DKPN_SANITIZER=thread + the channel stress test
|
||||
# (tests/test_channel_stress.cpp) is the dynamic half of verifying it. The
|
||||
# static half is the CDSChecker model-check harness (see verify/).
|
||||
set(KPN_SANITIZER "" CACHE STRING
|
||||
"Build with sanitizer: thread | address | undefined | <combo> (empty = off)")
|
||||
|
||||
# Translate KPN_SANITIZER into compile/link flags. No-op when empty.
|
||||
function(kpn_sanitizer_flags out_var)
|
||||
if(KPN_SANITIZER)
|
||||
set(${out_var}
|
||||
-fsanitize=${KPN_SANITIZER}
|
||||
-fno-omit-frame-pointer
|
||||
-g
|
||||
PARENT_SCOPE)
|
||||
else()
|
||||
set(${out_var} "" PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
# ── Core library (header-only) ────────────────────────────────────────────────
|
||||
add_library(kpn INTERFACE)
|
||||
target_include_directories(kpn INTERFACE
|
||||
|
||||
+9
-1
@@ -1,4 +1,4 @@
|
||||
# KPN++ Builder Image
|
||||
# KPN++ Builder Image (CI: pipeline trigger v2)
|
||||
# Pre-built image with GCC, CMake, Ninja, and Python dev headers for building and testing KPN++
|
||||
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/kpnpp-builder:latest .
|
||||
# Push: docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
|
||||
@@ -16,4 +16,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-install MkDocs dependencies so the docs workflow does not need to pip
|
||||
# install at runtime. --break-system-packages is required because the Debian
|
||||
# base marks the environment as externally managed (PEP 668); this is safe in
|
||||
# a dedicated container image.
|
||||
COPY docs/requirements.txt /tmp/docs-requirements.txt
|
||||
RUN pip install --no-cache-dir --break-system-packages -r /tmp/docs-requirements.txt \
|
||||
&& rm /tmp/docs-requirements.txt
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
# Performance investigation plan: fanout dispatch cost and deep-chain oversubscription
|
||||
|
||||
**Status:** phase 0 implemented; gate not yet cleared
|
||||
**Date:** 2026-08-06 (phase 0 landed 2026-08-06)
|
||||
**Baseline:** master @ 3b67b7e
|
||||
**Machine:** 20 cores, GCC 16.1.1, TBB 2023.1.0, AC power, `performance` governor
|
||||
**Data:** 7 full benchmark passes, medians reported below
|
||||
|
||||
---
|
||||
|
||||
## 1. What was measured
|
||||
|
||||
Throughput, items/sec, median of 7 passes. `N` is the sample size the harness
|
||||
uses for that row; it is what determines whether a row can be trusted at all.
|
||||
|
||||
### work_us = 10
|
||||
|
||||
| row | KPN it/s | TBB it/s | TBB faster | N | reliable? |
|
||||
|---|---|---|---|---|---|
|
||||
| chain-1 | 89381 | 91350 | +2.2% | 3000 | solid |
|
||||
| chain-2 | 83759 | 87017 | +3.9% | 1000 | solid |
|
||||
| chain-4 | 83725 | 85973 | +2.7% | 1000 | solid |
|
||||
| chain-8 | 79730 | 81090 | +1.7% | 1000 | solid |
|
||||
| **chain-16** | 53484 | 68745 | **+28.5%** | 200 | weak |
|
||||
| **chain-32** | 32780 | 45030 | **+37.4%** | 200 | weak |
|
||||
| **wide-4** | 84906 | 95137 | **+12.0%** | 3000 | solid |
|
||||
| diamond-4 | 84826 | 86772 | +2.3% | 1000 | solid |
|
||||
|
||||
### work_us = 100
|
||||
|
||||
Everything except chain-16/32 falls within ±3.4%, with KPN often ahead
|
||||
(chain-1 −0.6%, chain-4 −1.7%, chain-8 −3.4%, diamond −2.6% — negative means
|
||||
KPN faster). chain-16 is +12.5% and chain-32 +18.6%, both at N=50 and
|
||||
therefore unusable.
|
||||
|
||||
### Two deficits, different causes
|
||||
|
||||
1. **Fanout, +12%.** Solidly measured. `wide-4` performs ~5 node dispatches
|
||||
per item; the gap works out to a fixed ~250 ns per dispatch, consistent
|
||||
with `chain-1`'s ~290 ns over a single dispatch. This is dispatch
|
||||
efficiency.
|
||||
|
||||
2. **Deep chains, +28–37%.** The gap is 1.7–3.9% through depth 8, then jumps
|
||||
to 28.5% at depth 16 and 37.4% at depth 32. That is a cliff at core count,
|
||||
not a linear per-dispatch cost. `Node<>` owns a private `ThreadPool(1)`
|
||||
(`include/kpn/node.hpp:21`), so a depth-32 chain spawns 32 OS threads on
|
||||
20 cores. TBB bounds its worker count by hardware concurrency regardless of
|
||||
graph size.
|
||||
|
||||
### Scope note
|
||||
|
||||
At 100 µs+ per node KPN is at parity or ahead. The repository's own examples
|
||||
(OpenCV cellshade, frame sources, scene-actor extraction) do milliseconds of
|
||||
work per node, where a 290 ns dispatch cost is roughly one part in thirty
|
||||
thousand. Everything in this document matters only for fine-grained pipelines.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 0 — the gate that comes first
|
||||
|
||||
**Is there a target workload with sub-30 µs nodes?**
|
||||
|
||||
If no such workload exists or is planned, the correct output of this document
|
||||
is section 3 (harness) plus a README correction, and nothing else. Optimising
|
||||
for a benchmark regime the project does not operate in is not worth the risk
|
||||
described in section 6.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prerequisite — make the harness able to answer
|
||||
|
||||
None of the questions below are decidable with the current harness.
|
||||
`benchmarks/bench_pipeline.cpp` shrinks the sample count as work per item
|
||||
grows, so the rows under investigation run 50–200 items and swing 4–8×
|
||||
run to run.
|
||||
|
||||
| id | change | why |
|
||||
|---|---|---|
|
||||
| M1 | `items_for()` → fixed floor, e.g. `max(2000, …)`, independent of work_us and depth | deep rows are currently unmeasurable |
|
||||
| M2 | report items/sec as the primary metric; keep derived overhead as secondary | overhead is `elapsed − work`, a difference of large numbers; it magnifies noise roughly 10× |
|
||||
| M3 | K in-process repetitions per config; report median and IQR | one shot per config is the root of the present noise |
|
||||
| M4 | discard a warm-up repetition | first-touch page faults, thread spin-up |
|
||||
| M5 | extend `pool_sizes[]` to `{1,2,4,8,16,20}` | currently `{1,2,4}` — the configuration the README recommends is never run |
|
||||
| M6 | record nproc, governor and AC state in the CSV header | run-to-run attribution |
|
||||
|
||||
**Acceptance:** the same configuration run 7× lands within ±5% on every row.
|
||||
Until that holds, no number below should be acted on.
|
||||
|
||||
This touches only the benchmark, not the library.
|
||||
|
||||
### Status — implemented 2026-08-06
|
||||
|
||||
All of M1–M6 are in `benchmarks/bench_pipeline.cpp`, plus a CLI so the phase-1
|
||||
experiments are invocations rather than edits (`--depths`, `--pools`,
|
||||
`--work`, `--topos`, `--modes`, `--reps`, `--target-sec`, `--min-items`).
|
||||
|
||||
M1 is not a fixed floor but a time budget with a floor: sample size derives
|
||||
from `work_us × stages / units`, the steady-state throughput bound, then
|
||||
clamps to `[--min-items, --max-sec]`. A flat 2000-item floor would have made
|
||||
`chain-32` on a 1-thread pool at 1000 µs a 64-second row; the ceiling keeps
|
||||
such rows short and reports their true `N` so a short row is visible rather
|
||||
than silent. The old ladder's error was treating depth as a throughput cost —
|
||||
in a pipeline, depth beyond the core count costs throughput, below it only
|
||||
latency.
|
||||
|
||||
Also added, ahead of schedule because it is free: `ru_nivcsw` / `ru_nvcsw` per
|
||||
item are captured around every timed region, so **A3 is now a matter of
|
||||
reading a column** rather than a separate experiment.
|
||||
|
||||
`scripts/bench_repro_check.py` runs the acceptance criterion directly — K
|
||||
passes, per-row deviation from the median, non-zero exit if any row exceeds
|
||||
tolerance.
|
||||
|
||||
**Gate not yet cleared.** A 3-pass run of `chain-{1,8}` at 10 µs on the
|
||||
development laptop (20 cores, **powersave governor, on battery** — the header
|
||||
now records this) lands every row within 0.7%, against the 4–8× swings this
|
||||
section describes. That is encouraging but is not the acceptance run: it must
|
||||
be 7 passes over the full row set on the reference machine.
|
||||
|
||||
**Provisional and not to be acted on:** in that same run `chain-16` private
|
||||
was 6% behind TBB, not the 28.5% in the table above. If that survives the
|
||||
real acceptance run, the deep-chain deficit is substantially a measurement
|
||||
artefact of the N=200 rows and workstream A shrinks accordingly.
|
||||
|
||||
---
|
||||
|
||||
## 4. Workstream A — deep chains
|
||||
|
||||
**Hypothesis:** the deficit is thread oversubscription from the private-pool
|
||||
model, not dispatch cost.
|
||||
|
||||
### Investigation
|
||||
|
||||
| id | experiment | falsifies the hypothesis if |
|
||||
|---|---|---|
|
||||
| A1 | sweep depth 8, 12, 16, 20, 24, 32 at 10 µs, private pools | the cliff is not near nproc |
|
||||
| A2 | repeat A1 under `taskset -c 0-7` | the cliff does **not** move to ~depth 8 |
|
||||
| A3 | `getrusage(RUSAGE_SELF).ru_nivcsw` per item, depth 8 vs 32 | involuntary context switches do not scale with depth |
|
||||
| A4 | chain-16/32 on a shared pool sized 16 and 20, vs private and vs TBB | a correctly sized shared pool does not recover the gap |
|
||||
|
||||
A2 is decisive and costs one run: if the cliff tracks the core count, the
|
||||
mechanism is established.
|
||||
|
||||
### Improvement, conditional on A4
|
||||
|
||||
If a correctly sized shared pool closes the gap, this is not an optimisation
|
||||
problem — the mechanism already exists and is simply not the default:
|
||||
|
||||
- **A5** — change `Network`'s default from per-node private pools to a single
|
||||
shared pool sized `hardware_concurrency()`. Users should not have to know.
|
||||
- **A6** — emit a diagnostic when total node threads exceed
|
||||
`hardware_concurrency()`.
|
||||
- **A7** — README: state the threshold, with the measured cliff.
|
||||
|
||||
A5 is a change to the default execution model and must clear section 6 in full.
|
||||
|
||||
### A5 now has a prerequisite (from B1/B2, 2026-08-06)
|
||||
|
||||
The dispatch microbenchmark measured what a shared pool costs per dispatch,
|
||||
and it is not free: **466 ns on a private `ThreadPool(1)` against ~1.7 µs on a
|
||||
shared pool of 4**, because round-robin submission wakes a sleeping worker on
|
||||
every dispatch (see §5). A5 as written would therefore make every graph that
|
||||
currently fits inside its core count roughly 3–4× *worse* per dispatch, in
|
||||
exchange for fixing graphs that exceed it.
|
||||
|
||||
**A5 must not land before the wake cost does.** The order is B9/B5 first,
|
||||
then A5, and A4 must be read with this in mind: if a shared pool "recovers the
|
||||
gap" at depth 32, check what it costs at depth 4 in the same run before
|
||||
changing any default.
|
||||
|
||||
This partially inverts the prediction in §7: workstream A is not purely a
|
||||
default-and-documentation change, because the default it would switch to is
|
||||
currently the slower one per dispatch.
|
||||
|
||||
---
|
||||
|
||||
## 5. Workstream B — fanout dispatch cost
|
||||
|
||||
**Hypothesis:** a fixed ~250 ns per node dispatch, paid ~5× per item in
|
||||
`wide-4`. Unlike workstream A, this genuinely is dispatch efficiency.
|
||||
|
||||
Estimated budget for ~290 ns, per item — **estimates, to be replaced by B3**:
|
||||
|
||||
| cost | est. |
|
||||
|---|---|
|
||||
| `shared_lock(lifecycle_mx_)` in `submit()` | 20–40 ns |
|
||||
| `queues_[target]->mx` lock/unlock | 20–40 ns |
|
||||
| `priority_queue` push + pop (heap ops, `std::function` moves) | 50–100 ns |
|
||||
| `{ lock_guard lk(cv_mx_); }` + `notify_one()` | 20–40 ns, or µs if a worker actually sleeps |
|
||||
| 2–3 × `clock_t::now()` in `fire_once` | 50–75 ns |
|
||||
| gate CAS + ~6 stats atomics | 30–60 ns |
|
||||
|
||||
### Investigation — measure before touching anything
|
||||
|
||||
- **B1** — microbenchmark submit→execute turnaround for a null task on
|
||||
`ThreadPool(1)` and `ThreadPool(4)`. Yields ns/dispatch directly, in seconds
|
||||
rather than minutes.
|
||||
- **B2** — **does a worker actually sleep per item?** Count `cv_.wait` returns,
|
||||
or `strace -c -f -e futex`. The entire spin-window hypothesis depends on
|
||||
this; if workers are not sleeping, B5 is worthless and drops off the list.
|
||||
- **B3** — ablation, one variant per suspected cost, each measured against B1
|
||||
rather than guessed at:
|
||||
|
||||
| variant | suspected cost |
|
||||
|---|---|
|
||||
| stats and clock calls compiled out | 2–3 × `clock_t::now()` plus ~6 atomics per firing |
|
||||
| `priority_queue` → FIFO ring | heap operations, `std::function` moves |
|
||||
| `shared_lock(lifecycle_mx_)` removed (**measurement only, unsafe**) | `include/kpn/scheduler.hpp:113` |
|
||||
| bounded spin before sleeping | `include/kpn/scheduler.hpp:210-227` |
|
||||
|
||||
### B1/B2 — first results, 2026-08-06
|
||||
|
||||
`benchmarks/bench_dispatch.cpp` answers both without touching the library.
|
||||
Sleeping is inferred from `ru_nvcsw`: a thread blocking on a condition
|
||||
variable books a voluntary context switch, so voluntary switches per task is
|
||||
sleeps per task. Three modes, because "the cost of a dispatch" is three
|
||||
numbers: `latency` (idle pool, one task in flight), `batch` (submit flat out,
|
||||
drain once), `steady` (the task resubmits its successor, as `fire_once` does).
|
||||
|
||||
Laptop, powersave, battery, 3 reps — **the nanoseconds are provisional; the
|
||||
sleep counts are structural and will hold.** `steady`, 10 µs payload:
|
||||
|
||||
| pool threads | ns/dispatch | sleeps/task |
|
||||
|---|---|---|
|
||||
| 1 | 466 | **0.00** |
|
||||
| 2 | 1494 | 0.97 |
|
||||
| 4 | 1722 | 1.00 |
|
||||
| 8 | 1996 | 1.00 |
|
||||
|
||||
**B1 is answered and the abandon criterion is not met.** A `ThreadPool(1)`
|
||||
dispatch is 291 ns for a null task, 466 ns with a payload — against the ~290 ns
|
||||
the section-1 budget estimated for `chain-1`. The estimate was good. Dispatch
|
||||
cost is not already under 100 ns, so workstream B stays alive.
|
||||
|
||||
**B2 is answered, and the answer is conditional — which the question did not
|
||||
anticipate.** It is not "do workers sleep?" but "which pool?":
|
||||
|
||||
- On a private `ThreadPool(1)` — the `Node<>` default — the worker **never**
|
||||
sleeps. It resubmits into its own queue and finds the work already there.
|
||||
- On any pool of 2 or more, a worker sleeps **exactly once per task**.
|
||||
|
||||
`submit()` round-robins (`next_.fetch_add(1) % thread_count_`,
|
||||
`scheduler.hpp:131`), so on a shared pool every task is handed to a *different*
|
||||
worker, which is asleep, and every single dispatch pays a futex wake. That is
|
||||
the entire 466 ns → 1.7 µs difference.
|
||||
|
||||
Consequently **B5 (bounded spin) is worthless for the default configuration**
|
||||
and is the highest-value item for shared pools. It does not drop off the list,
|
||||
it moves onto a different one.
|
||||
|
||||
### B9 — submit-to-self affinity (new, not in the original plan)
|
||||
|
||||
If a `submit()` originating on a pool worker pushed to *that worker's own*
|
||||
queue instead of round-robining, the shared pool would inherit the property
|
||||
that makes `ThreadPool(1)` fast: the work is already local when the worker
|
||||
loops, so no wake. This is roughly what TBB does, and it plausibly subsumes
|
||||
most of B5 at lower risk — it changes task placement, not the sleep/wake
|
||||
protocol that the August wedge fixes hardened. Work stealing already exists to
|
||||
correct the resulting imbalance.
|
||||
|
||||
Measure before believing it: an affinity policy can starve peers, and
|
||||
`try_steal` only rebalances when a peer goes idle.
|
||||
|
||||
### Improvement — only what B3 shows pays
|
||||
|
||||
1. **B4 — compile-time-optional instrumentation.** No concurrency risk; the
|
||||
only item here that cannot reintroduce a wedge. Worth doing regardless.
|
||||
2. **B5 — bounded spin before sleeping**, mirroring the channel's existing
|
||||
`spin_count_` (~4 µs). Note the tension: b9698fa deliberately moved from
|
||||
"spin whenever any task runs" to "sleep as soon as nothing is queued" in
|
||||
order to fix pathological spinning. A *bounded* window is the middle
|
||||
ground; unbounded spin would undo that fix.
|
||||
3. **B6 — cheaper queue on the common path.** A private pool holds ≤1–2 tasks;
|
||||
`priority_queue<Task>` is heavy for that.
|
||||
4. **B7 — batched firing.** `fire_once` processes one token then re-submits;
|
||||
looping while inputs stay ready, bounded, amortises the submit, gate CAS
|
||||
and wake. The largest algorithmic win, but it changes latency and
|
||||
interacts with `compute_priority()`.
|
||||
5. **B8 — `lifecycle_mx_` off the hot path.** Last, and possibly never. It is
|
||||
load-bearing: it prevents `submit()` racing `stop()`'s `queues_.clear()`,
|
||||
a documented segfault reproducible "about 12 runs in 20".
|
||||
|
||||
**Abandon criteria:** if B1 shows dispatch cost already under ~100 ns, or the
|
||||
best surviving variant buys under 5%, stop and document the finding.
|
||||
|
||||
---
|
||||
|
||||
## 6. Guardrails
|
||||
|
||||
Both workstreams modify the machinery responsible for roughly twenty wedge
|
||||
fixes in August 2026, plus the lost wake fixed in 6802328. Every change:
|
||||
|
||||
1. **146/146** ctest, examples included.
|
||||
2. **Wedge soak before and after** — `benchmarks/repro_wedge.cpp`, ≥50k
|
||||
iterations clean. Reference point: the pre-6802328 code wedged 5/5 inside
|
||||
45 s, at iterations 149, 1249, 332, 1740 and 493.
|
||||
3. **ThreadSanitizer** on scheduler and pool_node tests for any change to
|
||||
either.
|
||||
4. **One change at a time**, measured independently. Bundling is how the
|
||||
August audit became twenty commits.
|
||||
5. **G1 — wire the reproducer in as an opt-in CTest stress target**
|
||||
(e.g. `-L soak`) so that performance work cannot silently reintroduce a
|
||||
wedge. This should land before either workstream starts.
|
||||
|
||||
### G1 — implemented 2026-08-06
|
||||
|
||||
`tests/soak_wedge.cpp` (supersedes `benchmarks/repro_wedge.cpp`, which was
|
||||
never wired into any build and can be deleted). Always compiled so it cannot
|
||||
rot; its CTest cases register only under `-DKPN_ENABLE_SOAK_TESTS=ON`, so the
|
||||
default `ctest` count is unchanged.
|
||||
|
||||
```
|
||||
cmake -B build -DKPN_ENABLE_SOAK_TESTS=ON -DKPN_SOAK_ITERS=50000
|
||||
cmake --build build --target kpn_soak_wedge
|
||||
ctest --test-dir build -L soak
|
||||
```
|
||||
|
||||
Two cases: `soak.wedge.pool` (depth 4, 4 threads — the configuration the
|
||||
August wedges were reproduced on) and `soak.wedge.private` (depth 8, one pool
|
||||
per node — the model workstream A would change). Both parameterised, so
|
||||
A5-style changes can be soaked at the depth that matters.
|
||||
|
||||
A wedge is a hang, and a hang under CTest is an unattributable timeout, so the
|
||||
binary carries a watchdog: if an iteration stops making progress for
|
||||
`--watchdog-sec` it aborts naming the iteration and the phase (`pushed`,
|
||||
`drained`, `nodes stopped`, `pool stopped`). Measured cost: ~13 ms per
|
||||
iteration, so the 50k-iteration guardrail is ~11 minutes.
|
||||
|
||||
**Guardrail 1 needs a correction.** The stated reference is 146/146; the
|
||||
tests-only configuration used here reports **136/136 passing**, and neither
|
||||
`examples/` nor `python/` registers any `add_test`. The true reference count
|
||||
must be pinned down before it is used to certify a change.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sequencing
|
||||
|
||||
| phase | contents | gate to proceed | state |
|
||||
|---|---|---|---|
|
||||
| 0 | workload question; M1–M6; G1 | ±5% reproducibility achieved | **tooling done**, acceptance run outstanding |
|
||||
| 1 | A1–A4 | A2 confirms the cliff tracks core count | harness supports it; not run |
|
||||
| 2 | A5–A7, or documentation only | A4 shows a shared pool recovers the gap | **now gated on B9/B5** |
|
||||
| 3 | B1–B3 | B2 answers the sleep question | **B1/B2 answered**; B3 outstanding |
|
||||
| 4 | B4, then whichever of B5–B8 survived B3 | each ≥5% and soak-clean | B5 rescoped to shared pools |
|
||||
|
||||
B1/B2 ran early because the microbenchmark cost seconds rather than minutes,
|
||||
and the result reordered phases 2 and 4 — the shared-pool default now depends
|
||||
on the wake cost being fixed first. Phase 1 is unchanged but its A4 row needs
|
||||
a shallow-depth control, per §4.
|
||||
|
||||
### Reproducing this
|
||||
|
||||
```
|
||||
cmake -B build_bench -DKPN_BUILD_BENCHMARKS=ON -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build_bench -j
|
||||
|
||||
# Phase 0 acceptance — must pass before any number below is acted on
|
||||
python3 scripts/bench_repro_check.py ./build_bench/benchmarks/bench_pipeline \
|
||||
--passes 7 --tolerance 5 -- --work=10,100 --reps=5
|
||||
|
||||
# B1/B2
|
||||
./build_bench/benchmarks/bench_dispatch --threads=1,2,4,8,20 --reps=5 \
|
||||
| tee dispatch.csv
|
||||
|
||||
# A1/A2 — the depth sweep, and the same under taskset to move the cliff
|
||||
./build_bench/benchmarks/bench_pipeline --work=10 --topos=chain \
|
||||
--depths=8,12,16,20,24,32 --modes=priv,tbb --reps=5 | tee a1.csv
|
||||
taskset -c 0-7 ./build_bench/benchmarks/bench_pipeline --work=10 \
|
||||
--topos=chain --depths=4,6,8,10,12,16,32 --modes=priv,tbb --reps=5 | tee a2.csv
|
||||
|
||||
# A4 — shared pool sized to the machine, against private and TBB.
|
||||
# Include a shallow depth: A5's risk is what a shared pool costs when the
|
||||
# graph already fits in its cores.
|
||||
./build_bench/benchmarks/bench_pipeline --work=10 --topos=chain \
|
||||
--depths=4,16,32 --pools=16,20 --reps=5 | tee a4.csv
|
||||
```
|
||||
|
||||
Check the `# governor=` line in each CSV before trusting it. A3 needs no
|
||||
separate run: `ivcsw_per_item` is a column in every row above.
|
||||
|
||||
**Success criteria**
|
||||
|
||||
- chain-32 @10 within 10% of TBB in the recommended configuration
|
||||
- wide-4 @10 within 5% of TBB
|
||||
- zero wedges across 100k soak iterations
|
||||
|
||||
**Prediction, recorded so it can be proven wrong:** workstream A resolves into
|
||||
a default-and-documentation change rather than an optimisation, and workstream
|
||||
B yields 5–10% on fanout from B4 and B5, with the remainder not worth the risk.
|
||||
|
||||
**Prediction, revised 2026-08-06 after B1/B2** — the original is already half
|
||||
wrong and is left above unedited:
|
||||
|
||||
- Workstream A does *not* resolve into a documentation change, because the
|
||||
shared pool it would recommend costs 3–4× more per dispatch than the private
|
||||
default. It resolves into B9 first.
|
||||
- The largest single win is not B4, B5 or B7 but **B9, submit-to-self
|
||||
affinity**: one sleep per dispatch is being paid on every shared pool, and
|
||||
eliminating it is worth roughly 1.2 µs per dispatch — far more than the
|
||||
5–10% predicted for fanout.
|
||||
- Standing: `chain-16`'s 28.5% deficit is a measurement artefact of N=200.
|
||||
|
||||
---
|
||||
|
||||
## 8. Related correction
|
||||
|
||||
Independently of the above, the README's TBB comparison overstates its case.
|
||||
The claim that KPN++ beats TBB "for every chain and diamond topology at
|
||||
100 µs/node" is not supported: at 100 µs only chain-1 and diamond lean KPN,
|
||||
while chain-16, chain-32 and wide-4 lean TBB. The tables are also quoted in
|
||||
derived overhead, which magnifies small differences — the same rows expressed
|
||||
as throughput are mostly within a few percent. Restating them in items/sec
|
||||
would be both more accurate and more favourable.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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
|
||||
@@ -398,8 +400,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, pure Python node *(pending)* |
|
||||
| `08_python_subport` | `net.read`, `net.write`, sub-port tap *(pending)* |
|
||||
| `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:
|
||||
@@ -467,7 +469,7 @@ not data.
|
||||
|
||||
Overhead µs/item at **work_us = 10** (framework overhead dominates):
|
||||
|
||||
| Topology | KPN private | TBB |
|
||||
| Topology | KPN++ | TBB |
|
||||
|---|---|---|
|
||||
| chain depth-1 | 1.7 | **1.4** |
|
||||
| chain depth-4 | 2.5 | **2.2** |
|
||||
@@ -479,7 +481,7 @@ Overhead µs/item at **work_us = 10** (framework overhead dominates):
|
||||
|
||||
Overhead µs/item at **work_us = 100** (moderate compute, KPN wins):
|
||||
|
||||
| Topology | KPN private | TBB |
|
||||
| Topology | KPN++ | TBB |
|
||||
|---|---|---|
|
||||
| chain depth-1 | **2.1** | 3.5 |
|
||||
| chain depth-4 | **4.3** | 5.2 |
|
||||
@@ -489,7 +491,7 @@ Overhead µs/item at **work_us = 100** (moderate compute, KPN wins):
|
||||
| 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
|
||||
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
|
||||
@@ -578,3 +580,33 @@ examples/
|
||||
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.
|
||||
|
||||
+37
-5
@@ -2,6 +2,8 @@
|
||||
|
||||
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
|
||||
@@ -220,8 +222,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, pure Python node *(pending)* |
|
||||
| `08_python_subport` | `net.read`, `net.write`, sub-port tap *(pending)* |
|
||||
| `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:
|
||||
@@ -289,7 +291,7 @@ not data.
|
||||
|
||||
Overhead µs/item at **work_us = 10** (framework overhead dominates):
|
||||
|
||||
| Topology | KPN private | TBB |
|
||||
| Topology | KPN++ | TBB |
|
||||
|---|---|---|
|
||||
| chain depth-1 | 1.7 | **1.4** |
|
||||
| chain depth-4 | 2.5 | **2.2** |
|
||||
@@ -301,7 +303,7 @@ Overhead µs/item at **work_us = 10** (framework overhead dominates):
|
||||
|
||||
Overhead µs/item at **work_us = 100** (moderate compute, KPN wins):
|
||||
|
||||
| Topology | KPN private | TBB |
|
||||
| Topology | KPN++ | TBB |
|
||||
|---|---|---|
|
||||
| chain depth-1 | **2.1** | 3.5 |
|
||||
| chain depth-4 | **4.3** | 5.2 |
|
||||
@@ -311,7 +313,7 @@ Overhead µs/item at **work_us = 100** (moderate compute, KPN wins):
|
||||
| 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
|
||||
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
|
||||
@@ -400,3 +402,33 @@ examples/
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,13 @@ add_executable(bench_pipeline bench_pipeline.cpp)
|
||||
target_link_libraries(bench_pipeline PRIVATE kpn)
|
||||
target_compile_options(bench_pipeline PRIVATE -O3 -march=native)
|
||||
|
||||
# Dispatch microbenchmark (PERF_PLAN B1/B2): ns per ThreadPool dispatch, and
|
||||
# whether a worker actually sleeps per task. No TBB comparison — it measures
|
||||
# KPN's own scheduler, not a competitor.
|
||||
add_executable(bench_dispatch bench_dispatch.cpp)
|
||||
target_link_libraries(bench_dispatch PRIVATE kpn)
|
||||
target_compile_options(bench_dispatch PRIVATE -O3 -march=native)
|
||||
|
||||
find_package(TBB QUIET)
|
||||
if(TBB_FOUND)
|
||||
target_link_libraries(bench_pipeline PRIVATE TBB::tbb)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// Dispatch microbenchmark — PERF_PLAN B1 and B2.
|
||||
//
|
||||
// B1 asks what a single ThreadPool dispatch costs. B2 asks whether a worker
|
||||
// actually sleeps per item, because the whole spin-window hypothesis (B5)
|
||||
// depends on the answer: if workers are not sleeping, a spin window buys
|
||||
// nothing and drops off the list.
|
||||
//
|
||||
// Both are answered here without touching the library. Sleeping is inferred
|
||||
// from ru_nvcsw — a thread blocking on a condition variable books a voluntary
|
||||
// context switch — so `vcsw/task` near 1.0 means a sleep per dispatch and near
|
||||
// 0 means the worker never went to sleep at all.
|
||||
//
|
||||
// Three modes, because "the cost of a dispatch" is three different numbers:
|
||||
//
|
||||
// latency — one task in flight, pool idle in between. The worker is asleep
|
||||
// at every submission, so this is dispatch cost *including* a
|
||||
// wake. Worst case, and the case a spin window would attack.
|
||||
//
|
||||
// batch — submit K no-op tasks flat out, then drain. The worker is never
|
||||
// idle, so this is the amortised floor: queue and heap operations
|
||||
// with no wake at all. Reports the producer-side submit() cost
|
||||
// separately from end-to-end throughput.
|
||||
//
|
||||
// steady — the task resubmits its successor, one in flight, each doing
|
||||
// --work-us of work. This is what a KPN node actually does:
|
||||
// fire_once processes a token and resubmits. On ThreadPool(1) the
|
||||
// worker resubmits to its own queue; on ThreadPool(4) round-robin
|
||||
// hands the task to a *different* worker, which may be asleep.
|
||||
// That difference is the fanout cost wide-4 pays ~5x per item.
|
||||
//
|
||||
// Usage: ./bench_dispatch [--threads=1,2,4] [--mode=latency,batch,steady]
|
||||
// [--tasks=200000] [--work-us=0] [--reps=5] [--warmup=1]
|
||||
|
||||
#include <kpn/kpn.hpp>
|
||||
|
||||
#include "bench_env.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace kpn;
|
||||
using sclock = std::chrono::steady_clock;
|
||||
|
||||
struct Opts {
|
||||
std::vector<int> threads {1, 2, 4};
|
||||
std::vector<std::string> modes {"latency", "batch", "steady"};
|
||||
long tasks = 200000;
|
||||
int work_us = 0;
|
||||
int reps = 5;
|
||||
int warmup = 1;
|
||||
};
|
||||
|
||||
static Opts g_opts;
|
||||
|
||||
static void busy_us(int us) {
|
||||
if (us <= 0) return;
|
||||
auto end = sclock::now() + std::chrono::microseconds(us);
|
||||
while (sclock::now() < end);
|
||||
}
|
||||
|
||||
struct Sample {
|
||||
double ns_per_dispatch = 0; // end-to-end, minus the work payload
|
||||
double submit_ns = 0; // producer side only (batch mode)
|
||||
double vcsw_per_task = 0; // B2: sleeps per dispatch
|
||||
double ivcsw_per_task = 0;
|
||||
};
|
||||
|
||||
// ── latency: one task at a time, worker asleep between submissions ────────────
|
||||
|
||||
static Sample run_latency(int threads, long tasks) {
|
||||
ThreadPool pool(threads);
|
||||
pool.start();
|
||||
|
||||
std::mutex mx;
|
||||
std::condition_variable cv;
|
||||
bool done = false;
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
for (long i = 0; i < tasks; ++i) {
|
||||
{ std::lock_guard lk(mx); done = false; }
|
||||
pool.submit([&] {
|
||||
busy_us(g_opts.work_us);
|
||||
{ std::lock_guard lk(mx); done = true; }
|
||||
cv.notify_one();
|
||||
});
|
||||
std::unique_lock lk(mx);
|
||||
cv.wait(lk, [&] { return done; });
|
||||
}
|
||||
auto t1 = sclock::now();
|
||||
Sample s;
|
||||
long iv = 0, vc = 0;
|
||||
ru.finish(iv, vc);
|
||||
pool.stop();
|
||||
|
||||
double elapsed_ns = std::chrono::duration<double, std::nano>(t1 - t0).count();
|
||||
s.ns_per_dispatch = elapsed_ns / tasks - g_opts.work_us * 1000.0;
|
||||
// The requesting thread blocks once per task too, so it books a voluntary
|
||||
// switch of its own; halve to attribute per side rather than per process.
|
||||
s.vcsw_per_task = static_cast<double>(vc) / tasks / 2.0;
|
||||
s.ivcsw_per_task = static_cast<double>(iv) / tasks;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── batch: submit flat out, drain once. No wake in the steady state ───────────
|
||||
|
||||
static Sample run_batch(int threads, long tasks) {
|
||||
ThreadPool pool(threads);
|
||||
pool.start();
|
||||
|
||||
std::atomic<long> ran{0};
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
for (long i = 0; i < tasks; ++i)
|
||||
pool.submit([&] {
|
||||
busy_us(g_opts.work_us);
|
||||
ran.fetch_add(1, std::memory_order_relaxed);
|
||||
});
|
||||
auto t_submitted = sclock::now();
|
||||
pool.drain();
|
||||
auto t1 = sclock::now();
|
||||
Sample s;
|
||||
long iv = 0, vc = 0;
|
||||
ru.finish(iv, vc);
|
||||
pool.stop();
|
||||
|
||||
if (ran.load() != tasks)
|
||||
std::fprintf(stderr, "WARNING: batch ran %ld of %ld tasks\n",
|
||||
ran.load(), tasks);
|
||||
|
||||
double elapsed_ns = std::chrono::duration<double, std::nano>(t1 - t0).count();
|
||||
s.ns_per_dispatch = elapsed_ns / tasks - g_opts.work_us * 1000.0;
|
||||
s.submit_ns = std::chrono::duration<double, std::nano>(t_submitted - t0).count() / tasks;
|
||||
s.vcsw_per_task = static_cast<double>(vc) / tasks;
|
||||
s.ivcsw_per_task = static_cast<double>(iv) / tasks;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── steady: the task resubmits its successor, as fire_once does ──────────────
|
||||
|
||||
static Sample run_steady(int threads, long tasks) {
|
||||
ThreadPool pool(threads);
|
||||
pool.start();
|
||||
|
||||
std::mutex mx;
|
||||
std::condition_variable cv;
|
||||
std::atomic<long> count{0};
|
||||
bool finished = false;
|
||||
// Recursive submission: hold the chain in a std::function so the task can
|
||||
// resubmit itself. Captured by reference; it outlives the drain below.
|
||||
//
|
||||
// The counter is atomic rather than mutex-guarded so that this loop
|
||||
// measures the pool's dispatch path and not a lock of the benchmark's own.
|
||||
std::function<void()> step = [&] {
|
||||
busy_us(g_opts.work_us);
|
||||
long n = count.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
if (n < tasks) {
|
||||
pool.submit(step);
|
||||
} else {
|
||||
{ std::lock_guard lk(mx); finished = true; }
|
||||
cv.notify_one();
|
||||
}
|
||||
};
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
pool.submit(step);
|
||||
{
|
||||
std::unique_lock lk(mx);
|
||||
cv.wait(lk, [&] { return finished; });
|
||||
}
|
||||
auto t1 = sclock::now();
|
||||
Sample s;
|
||||
long iv = 0, vc = 0;
|
||||
ru.finish(iv, vc);
|
||||
pool.stop();
|
||||
|
||||
double elapsed_ns = std::chrono::duration<double, std::nano>(t1 - t0).count();
|
||||
s.ns_per_dispatch = elapsed_ns / tasks - g_opts.work_us * 1000.0;
|
||||
s.vcsw_per_task = static_cast<double>(vc) / tasks;
|
||||
s.ivcsw_per_task = static_cast<double>(iv) / tasks;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── driver ────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void run_row(const std::string& mode, int threads, long tasks) {
|
||||
auto once = [&] {
|
||||
if (mode == "latency") return run_latency(threads, tasks);
|
||||
if (mode == "batch") return run_batch(threads, tasks);
|
||||
return run_steady(threads, tasks);
|
||||
};
|
||||
|
||||
for (int i = 0; i < g_opts.warmup; ++i) (void)once();
|
||||
|
||||
std::vector<double> ns, sub, vcsw, ivcsw;
|
||||
for (int i = 0; i < g_opts.reps; ++i) {
|
||||
Sample s = once();
|
||||
ns.push_back(s.ns_per_dispatch);
|
||||
sub.push_back(s.submit_ns);
|
||||
vcsw.push_back(s.vcsw_per_task);
|
||||
ivcsw.push_back(s.ivcsw_per_task);
|
||||
}
|
||||
|
||||
const double med = bench::percentile(ns, 0.5);
|
||||
const double q1 = bench::percentile(ns, 0.25);
|
||||
const double q3 = bench::percentile(ns, 0.75);
|
||||
const double iqr = med > 0 ? 100.0 * (q3 - q1) / med : 0.0;
|
||||
const double sleeps = bench::percentile(vcsw, 0.5);
|
||||
|
||||
std::fprintf(stderr, "%-9s %-8d %-8d %-10ld %-12.0f %-7.1f %-11.0f %-10.2f %-10.2f\n",
|
||||
mode.c_str(), threads, g_opts.work_us, tasks, med, iqr,
|
||||
bench::percentile(sub, 0.5), sleeps,
|
||||
bench::percentile(ivcsw, 0.5));
|
||||
// Column names deliberately match bench_pipeline's key columns so that
|
||||
// scripts/bench_repro_check.py can gate this benchmark too.
|
||||
std::printf("%s,%d,%d,%d,%ld,%d,%.1f,%.2f,%.1f,%.3f,%.3f\n",
|
||||
mode.c_str(), threads, g_opts.work_us, threads, tasks,
|
||||
g_opts.reps, med, iqr, bench::percentile(sub, 0.5),
|
||||
sleeps, bench::percentile(ivcsw, 0.5));
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
static std::vector<int> parse_int_list(const char* s) {
|
||||
std::vector<int> out;
|
||||
const char* p = s;
|
||||
while (*p) {
|
||||
char* end = nullptr;
|
||||
long v = std::strtol(p, &end, 10);
|
||||
if (end == p) break;
|
||||
out.push_back(static_cast<int>(v));
|
||||
p = end;
|
||||
while (*p == ',' || *p == ' ') ++p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::vector<std::string> parse_word_list(const std::string& s) {
|
||||
std::vector<std::string> out;
|
||||
std::size_t pos = 0;
|
||||
while (pos <= s.size()) {
|
||||
std::size_t c = s.find(',', pos);
|
||||
if (c == std::string::npos) c = s.size();
|
||||
if (c > pos) out.push_back(s.substr(pos, c - pos));
|
||||
pos = c + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static void usage() {
|
||||
std::fprintf(stderr,
|
||||
"usage: bench_dispatch [options]\n"
|
||||
" --threads=1,2,4 pool sizes\n"
|
||||
" --mode=latency,batch,steady which measurements to run\n"
|
||||
" --tasks=200000 dispatches per repetition\n"
|
||||
" --work-us=0 payload per task\n"
|
||||
" --reps=5 --warmup=1\n");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string a = argv[i];
|
||||
auto eq = a.find('=');
|
||||
std::string key = a.substr(0, eq);
|
||||
std::string val = eq == std::string::npos ? "" : a.substr(eq + 1);
|
||||
|
||||
if (key == "--help" || key == "-h") { usage(); return 0; }
|
||||
else if (key == "--threads") g_opts.threads = parse_int_list(val.c_str());
|
||||
else if (key == "--mode") g_opts.modes = parse_word_list(val);
|
||||
else if (key == "--tasks") g_opts.tasks = std::atol(val.c_str());
|
||||
else if (key == "--work-us") g_opts.work_us = std::atoi(val.c_str());
|
||||
else if (key == "--reps") g_opts.reps = std::atoi(val.c_str());
|
||||
else if (key == "--warmup") g_opts.warmup = std::atoi(val.c_str());
|
||||
else { std::fprintf(stderr, "unknown option: %s\n", a.c_str()); usage(); return 2; }
|
||||
}
|
||||
if (g_opts.reps < 1) g_opts.reps = 1;
|
||||
if (g_opts.warmup < 0) g_opts.warmup = 0;
|
||||
|
||||
char cfg[160];
|
||||
std::snprintf(cfg, sizeof cfg, "tasks=%ld work_us=%d reps=%d warmup=%d",
|
||||
g_opts.tasks, g_opts.work_us, g_opts.reps, g_opts.warmup);
|
||||
bench::print_environment(cfg);
|
||||
|
||||
std::fprintf(stderr, "\n%-9s %-8s %-8s %-10s %-12s %-7s %-11s %-10s %-10s\n",
|
||||
"mode", "threads", "work_us", "tasks", "ns/dispatch", "iqr%",
|
||||
"submit_ns", "vcsw/task", "ivcsw/task");
|
||||
std::fprintf(stderr, "%s\n", std::string(96, '-').c_str());
|
||||
std::printf("topology,size,work_us,threads,items,reps,ns_per_dispatch,"
|
||||
"iqr_pct,submit_ns,vcsw_per_task,ivcsw_per_task\n");
|
||||
|
||||
// latency is a round trip per task, so it is far slower per dispatch than
|
||||
// the other modes; scale it down rather than run for minutes.
|
||||
for (const auto& mode : g_opts.modes)
|
||||
for (int t : g_opts.threads) {
|
||||
long tasks = mode == "latency"
|
||||
? std::max(2000L, g_opts.tasks / 20)
|
||||
: g_opts.tasks;
|
||||
run_row(mode, t, tasks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Shared benchmark plumbing: machine attribution (PERF_PLAN M6), repetition
|
||||
// statistics (M3), and context-switch capture.
|
||||
//
|
||||
// The attribution is not decoration. A result taken under the powersave
|
||||
// governor or on battery is not comparable with one taken on AC under
|
||||
// performance, and a stored CSV that does not say which it was cannot be
|
||||
// argued about later.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/resource.h>
|
||||
|
||||
namespace bench {
|
||||
|
||||
inline int hw_units() {
|
||||
unsigned n = std::thread::hardware_concurrency();
|
||||
return n ? static_cast<int>(n) : 1;
|
||||
}
|
||||
|
||||
inline std::string read_line_of(const char* path) {
|
||||
std::FILE* f = std::fopen(path, "r");
|
||||
if (!f) return "unknown";
|
||||
char buf[128] = {0};
|
||||
if (!std::fgets(buf, sizeof buf, f)) { std::fclose(f); return "unknown"; }
|
||||
std::fclose(f);
|
||||
std::string s(buf);
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == ' ')) s.pop_back();
|
||||
return s.empty() ? "unknown" : s;
|
||||
}
|
||||
|
||||
inline std::string ac_state() {
|
||||
for (const char* p : {"/sys/class/power_supply/AC/online",
|
||||
"/sys/class/power_supply/AC0/online",
|
||||
"/sys/class/power_supply/ACAD/online",
|
||||
"/sys/class/power_supply/ADP1/online"}) {
|
||||
std::string v = read_line_of(p);
|
||||
if (v != "unknown") return v == "1" ? "ac" : "battery";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// M6 — emitted to both streams: the CSV so a stored result can be attributed,
|
||||
// the terminal so a run under the wrong governor is noticed while it happens.
|
||||
inline void print_environment(const std::string& config_line) {
|
||||
const std::string gov = read_line_of(
|
||||
"/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor");
|
||||
const std::string ac = ac_state();
|
||||
|
||||
for (std::FILE* out : {stdout, stderr}) {
|
||||
std::fprintf(out, "# nproc=%d governor=%s power=%s\n",
|
||||
hw_units(), gov.c_str(), ac.c_str());
|
||||
if (!config_line.empty())
|
||||
std::fprintf(out, "# %s\n", config_line.c_str());
|
||||
#if defined(__GNUC__) && !defined(__clang__)
|
||||
std::fprintf(out, "# compiler=gcc-%d.%d.%d\n",
|
||||
__GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__);
|
||||
#elif defined(__clang__)
|
||||
std::fprintf(out, "# compiler=clang-%d.%d.%d\n",
|
||||
__clang_major__, __clang_minor__, __clang_patchlevel__);
|
||||
#endif
|
||||
}
|
||||
if (gov != "performance" || ac == "battery")
|
||||
std::fprintf(stderr,
|
||||
"# WARNING: governor=%s power=%s — results are not comparable with\n"
|
||||
"# a run on AC power under the performance governor.\n",
|
||||
gov.c_str(), ac.c_str());
|
||||
}
|
||||
|
||||
inline double percentile(std::vector<double> v, double p) {
|
||||
if (v.empty()) return 0;
|
||||
std::sort(v.begin(), v.end());
|
||||
double idx = p * (v.size() - 1);
|
||||
auto lo = static_cast<std::size_t>(std::floor(idx));
|
||||
auto hi = static_cast<std::size_t>(std::ceil(idx));
|
||||
return v[lo] + (v[hi] - v[lo]) * (idx - lo);
|
||||
}
|
||||
|
||||
// Process-wide context-switch counters, sampled around a timed region.
|
||||
//
|
||||
// ru_nvcsw (voluntary) is the cheap answer to PERF_PLAN B2: a thread that
|
||||
// blocks on a condition variable books a voluntary switch, so voluntary
|
||||
// switches per dispatch is, near enough, sleeps per dispatch. ru_nivcsw
|
||||
// (involuntary) is preemption, which is what oversubscription looks like (A3).
|
||||
struct RusageDelta {
|
||||
long ivcsw0 = 0, vcsw0 = 0;
|
||||
|
||||
void start() {
|
||||
rusage ru{};
|
||||
getrusage(RUSAGE_SELF, &ru);
|
||||
ivcsw0 = ru.ru_nivcsw;
|
||||
vcsw0 = ru.ru_nvcsw;
|
||||
}
|
||||
void finish(long& nivcsw, long& nvcsw) const {
|
||||
rusage ru{};
|
||||
getrusage(RUSAGE_SELF, &ru);
|
||||
nivcsw = ru.ru_nivcsw - ivcsw0;
|
||||
nvcsw = ru.ru_nvcsw - vcsw0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace bench
|
||||
+360
-122
@@ -9,24 +9,38 @@
|
||||
// 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
|
||||
// Each row is run --reps times (plus discarded warm-up runs); the reported
|
||||
// figure is the median items/sec, with the inter-quartile spread as a
|
||||
// reliability indicator. A row whose iqr_pct is above a few percent is not
|
||||
// measuring what it claims to measure.
|
||||
//
|
||||
// Usage: ./bench_pipeline [options] | tee results.csv
|
||||
// ./bench_pipeline --help
|
||||
|
||||
#include <kpn/kpn.hpp>
|
||||
|
||||
#include "bench_env.hpp"
|
||||
|
||||
#ifdef KPN_BENCH_TBB
|
||||
#include <oneapi/tbb/flow_graph.h>
|
||||
namespace tbb_flow = oneapi::tbb::flow;
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/resource.h>
|
||||
|
||||
using namespace kpn;
|
||||
using namespace std::chrono_literals;
|
||||
using sclock = std::chrono::steady_clock;
|
||||
@@ -57,31 +71,63 @@ static void push_retry(Channel<int>& ch, int val) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── result ────────────────────────────────────────────────────────────────────
|
||||
// ── configuration (M1, M3, M4, M5) ────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
struct Config {
|
||||
std::vector<int> work_amts {10, 100, 1000};
|
||||
std::vector<int> pool_sizes{1, 2, 4, 8, 16, 20}; // M5
|
||||
std::vector<int> depths {1, 2, 4, 8, 16, 32};
|
||||
std::vector<int> widths {1, 2, 3, 4};
|
||||
int reps = 5; // M3: measured repetitions per row
|
||||
int warmup = 1; // M4: discarded repetitions per row
|
||||
double target_sec = 0.30; // aimed-for duration of one repetition
|
||||
long min_items = 2000; // M1: floor, independent of work_us and depth
|
||||
double max_sec = 3.0; // ceiling; only bites where min_items cannot fit
|
||||
bool do_chain = true, do_wide = true, do_diamond = true;
|
||||
bool do_priv = true, do_pool = true, do_tbb = true;
|
||||
};
|
||||
|
||||
static Config g_cfg;
|
||||
|
||||
// M1 — sample size from a time budget with a hard floor, rather than a
|
||||
// hand-tuned ladder that collapsed to 50–200 items on exactly the rows under
|
||||
// investigation.
|
||||
//
|
||||
// `stages` is the number of node firings per item; `units` the number of
|
||||
// threads able to run them concurrently. Steady-state throughput of the
|
||||
// pipeline is bounded by work_us * stages / units, so that is the per-item
|
||||
// cost the sample size is derived from. Depth beyond `units` costs throughput;
|
||||
// depth below it costs only latency, which does not scale the run.
|
||||
static long pick_items(int work_us, int stages, int units) {
|
||||
units = std::max(1, std::min(units, bench::hw_units()));
|
||||
const double per_item_us =
|
||||
std::max(1.0, static_cast<double>(work_us)) *
|
||||
std::max(1.0, static_cast<double>(stages) / units);
|
||||
|
||||
long want = static_cast<long>(g_cfg.target_sec * 1e6 / per_item_us);
|
||||
long cap = static_cast<long>(g_cfg.max_sec * 1e6 / per_item_us);
|
||||
|
||||
want = std::max(want, g_cfg.min_items);
|
||||
// The floor wins unless honouring it would blow the time ceiling by more
|
||||
// than the ceiling allows; such rows are reported with their true N so the
|
||||
// reader can see they are short.
|
||||
if (want > cap) want = std::max(cap, 200L);
|
||||
return want;
|
||||
}
|
||||
|
||||
// ── one measured repetition ───────────────────────────────────────────────────
|
||||
|
||||
struct Sample {
|
||||
double items_per_sec = 0;
|
||||
double overhead_us = 0;
|
||||
long nivcsw = 0; // involuntary context switches during the run
|
||||
long nvcsw = 0; // voluntary context switches during the run
|
||||
};
|
||||
|
||||
// ── 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;
|
||||
static Sample bench_chain(int depth, int work_us, long N) {
|
||||
const std::size_t CAP = static_cast<std::size_t>(N);
|
||||
|
||||
std::vector<std::shared_ptr<Channel<int>>> chs;
|
||||
for (int i = 0; i <= depth; ++i)
|
||||
@@ -98,17 +144,20 @@ static Result bench_chain(int depth, int work_us) {
|
||||
|
||||
std::atomic<sclock::time_point> t1;
|
||||
std::thread reader([&] {
|
||||
for (int i = 0; i < N; ++i) chs.back()->pop();
|
||||
for (long i = 0; i < N; ++i) chs.back()->pop();
|
||||
t1.store(sclock::now(), std::memory_order_release);
|
||||
});
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
std::thread pusher([&] {
|
||||
for (int i = 0; i < N; ++i) push_retry(*chs[0], i);
|
||||
for (long i = 0; i < N; ++i) push_retry(*chs[0], static_cast<int>(i));
|
||||
});
|
||||
|
||||
pusher.join();
|
||||
reader.join();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
for (auto& n : nodes) n->stop();
|
||||
|
||||
double elapsed = std::chrono::duration<double>(
|
||||
@@ -116,13 +165,13 @@ static Result bench_chain(int depth, int work_us) {
|
||||
// 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};
|
||||
s.overhead_us = (elapsed * 1e6 - pipeline_us) / N;
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
|
||||
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;
|
||||
static Sample bench_chain_pool(int depth, int work_us, int pool_threads, long N) {
|
||||
const std::size_t CAP = static_cast<std::size_t>(N);
|
||||
|
||||
auto pool = std::make_shared<ThreadPool>(pool_threads);
|
||||
|
||||
@@ -142,33 +191,36 @@ static Result bench_chain_pool(int depth, int work_us, int pool_threads) {
|
||||
|
||||
std::atomic<sclock::time_point> t1;
|
||||
std::thread reader([&] {
|
||||
for (int i = 0; i < N; ++i) chs.back()->pop();
|
||||
for (long i = 0; i < N; ++i) chs.back()->pop();
|
||||
t1.store(sclock::now(), std::memory_order_release);
|
||||
});
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
std::thread pusher([&] {
|
||||
for (int i = 0; i < N; ++i) push_retry(*chs[0], i);
|
||||
for (long i = 0; i < N; ++i) push_retry(*chs[0], static_cast<int>(i));
|
||||
});
|
||||
|
||||
pusher.join();
|
||||
reader.join();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
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};
|
||||
s.overhead_us = (elapsed * 1e6 - pipeline_us) / N;
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
static Sample bench_wide(int work_us, long N) {
|
||||
const std::size_t CAP = static_cast<std::size_t>(N);
|
||||
|
||||
auto src_ch = std::make_shared<Channel<int>>(CAP);
|
||||
auto fan = std::make_unique<FanoutNode<int, W>>(CAP);
|
||||
@@ -197,33 +249,36 @@ static Result bench_wide(int work_us) {
|
||||
|
||||
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();
|
||||
for (long 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);
|
||||
});
|
||||
}
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
std::thread pusher([&] {
|
||||
for (int i = 0; i < N; ++i) push_retry(*src_ch, i);
|
||||
for (long i = 0; i < N; ++i) push_retry(*src_ch, static_cast<int>(i));
|
||||
});
|
||||
|
||||
pusher.join();
|
||||
for (auto& r : readers) r.join();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
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};
|
||||
s.overhead_us = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
|
||||
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;
|
||||
static Sample bench_wide_pool(int work_us, int pool_threads, long N) {
|
||||
const std::size_t CAP = static_cast<std::size_t>(N);
|
||||
|
||||
auto pool = std::make_shared<ThreadPool>(pool_threads);
|
||||
auto src_ch = std::make_shared<Channel<int>>(CAP);
|
||||
@@ -254,35 +309,38 @@ static Result bench_wide_pool(int work_us, int pool_threads) {
|
||||
|
||||
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();
|
||||
for (long 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);
|
||||
});
|
||||
}
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
std::thread pusher([&] {
|
||||
for (int i = 0; i < N; ++i) push_retry(*src_ch, i);
|
||||
for (long i = 0; i < N; ++i) push_retry(*src_ch, static_cast<int>(i));
|
||||
});
|
||||
|
||||
pusher.join();
|
||||
for (auto& r : readers) r.join();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
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};
|
||||
s.overhead_us = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── diamond ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static Result bench_diamond(int work_us) {
|
||||
const int N = items_for(work_us, 2);
|
||||
const int CAP = N;
|
||||
static Sample bench_diamond(int work_us, long N) {
|
||||
const std::size_t CAP = static_cast<std::size_t>(N);
|
||||
|
||||
auto src_ch = std::make_shared<Channel<int>>(CAP);
|
||||
auto fan = std::make_unique<FanoutNode<int, 2>>(CAP);
|
||||
@@ -312,7 +370,7 @@ static Result bench_diamond(int work_us) {
|
||||
std::atomic<int> done{0};
|
||||
auto make_reader = [&](Channel<int>& ch) {
|
||||
return std::thread([&] {
|
||||
for (int i = 0; i < N; ++i) ch.pop();
|
||||
for (long 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);
|
||||
});
|
||||
@@ -320,23 +378,26 @@ static Result bench_diamond(int work_us) {
|
||||
auto rL = make_reader(*snkL);
|
||||
auto rR = make_reader(*snkR);
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
std::thread pusher([&] {
|
||||
for (int i = 0; i < N; ++i) push_retry(*src_ch, i);
|
||||
for (long i = 0; i < N; ++i) push_retry(*src_ch, static_cast<int>(i));
|
||||
});
|
||||
|
||||
pusher.join(); rL.join(); rR.join();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
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};
|
||||
s.overhead_us = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
|
||||
static Result bench_diamond_pool(int work_us, int pool_threads) {
|
||||
const int N = items_for(work_us, 2);
|
||||
const int CAP = N;
|
||||
static Sample bench_diamond_pool(int work_us, int pool_threads, long N) {
|
||||
const std::size_t CAP = static_cast<std::size_t>(N);
|
||||
|
||||
auto pool = std::make_shared<ThreadPool>(pool_threads);
|
||||
auto src_ch = std::make_shared<Channel<int>>(CAP);
|
||||
@@ -369,7 +430,7 @@ static Result bench_diamond_pool(int work_us, int pool_threads) {
|
||||
std::atomic<int> done{0};
|
||||
auto make_reader = [&](Channel<int>& ch) {
|
||||
return std::thread([&] {
|
||||
for (int i = 0; i < N; ++i) ch.pop();
|
||||
for (long 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);
|
||||
});
|
||||
@@ -377,28 +438,30 @@ static Result bench_diamond_pool(int work_us, int pool_threads) {
|
||||
auto rL = make_reader(*snkL);
|
||||
auto rR = make_reader(*snkR);
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
std::thread pusher([&] {
|
||||
for (int i = 0; i < N; ++i) push_retry(*src_ch, i);
|
||||
for (long i = 0; i < N; ++i) push_retry(*src_ch, static_cast<int>(i));
|
||||
});
|
||||
|
||||
pusher.join(); rL.join(); rR.join();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
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};
|
||||
s.overhead_us = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
static Sample bench_chain_tbb(int depth, int work_us, long N) {
|
||||
tbb_flow::graph g;
|
||||
using FN = tbb_flow::function_node<int, int>;
|
||||
std::vector<std::unique_ptr<FN>> nodes;
|
||||
@@ -409,21 +472,23 @@ static Result bench_chain_tbb(int depth, int work_us) {
|
||||
for (int i = 0; i + 1 < depth; ++i)
|
||||
tbb_flow::make_edge(*nodes[i], *nodes[i + 1]);
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
for (int i = 0; i < N; ++i) nodes[0]->try_put(i);
|
||||
for (long i = 0; i < N; ++i) nodes[0]->try_put(static_cast<int>(i));
|
||||
g.wait_for_all();
|
||||
auto t1 = sclock::now();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
|
||||
double elapsed = std::chrono::duration<double>(t1 - t0).count();
|
||||
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};
|
||||
s.overhead_us = (elapsed * 1e6 - pipeline_us) / N;
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
|
||||
template<std::size_t W>
|
||||
static Result bench_wide_tbb(int work_us) {
|
||||
const int N = items_for(work_us);
|
||||
|
||||
static Sample bench_wide_tbb(int work_us, long N) {
|
||||
tbb_flow::graph g;
|
||||
tbb_flow::broadcast_node<int> fan(g);
|
||||
using FN = tbb_flow::function_node<int, int>;
|
||||
@@ -434,19 +499,21 @@ static Result bench_wide_tbb(int work_us) {
|
||||
tbb_flow::make_edge(fan, *n);
|
||||
}
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
for (int i = 0; i < N; ++i) fan.try_put(i);
|
||||
for (long i = 0; i < N; ++i) fan.try_put(static_cast<int>(i));
|
||||
g.wait_for_all();
|
||||
auto t1 = sclock::now();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
|
||||
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};
|
||||
s.overhead_us = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
|
||||
static Result bench_diamond_tbb(int work_us) {
|
||||
const int N = items_for(work_us, 2);
|
||||
|
||||
static Sample bench_diamond_tbb(int work_us, long N) {
|
||||
tbb_flow::graph g;
|
||||
tbb_flow::broadcast_node<int> fan(g);
|
||||
using FN = tbb_flow::function_node<int, int>;
|
||||
@@ -456,71 +523,242 @@ static Result bench_diamond_tbb(int work_us) {
|
||||
tbb_flow::make_edge(fan, nL); tbb_flow::make_edge(fan, nR);
|
||||
tbb_flow::make_edge(nL, nL2); tbb_flow::make_edge(nR, nR2);
|
||||
|
||||
bench::RusageDelta ru; ru.start();
|
||||
auto t0 = sclock::now();
|
||||
for (int i = 0; i < N; ++i) fan.try_put(i);
|
||||
for (long i = 0; i < N; ++i) fan.try_put(static_cast<int>(i));
|
||||
g.wait_for_all();
|
||||
auto t1 = sclock::now();
|
||||
Sample s;
|
||||
ru.finish(s.nivcsw, s.nvcsw);
|
||||
|
||||
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};
|
||||
s.overhead_us = (elapsed * 1e6) / N - static_cast<double>(work_us);
|
||||
s.items_per_sec = N / elapsed;
|
||||
return s;
|
||||
}
|
||||
#endif // KPN_BENCH_TBB
|
||||
|
||||
// ── repetition driver (M2, M3, M4) ────────────────────────────────────────────
|
||||
|
||||
using bench::percentile;
|
||||
|
||||
// A row: median of `reps` repetitions, after `warmup` discarded ones.
|
||||
// M2 — items/sec is the primary figure; derived overhead is secondary,
|
||||
// because it is a difference of large numbers and magnifies noise ~10×.
|
||||
template<class Fn>
|
||||
static void run_row(const char* topology, int size, int work_us, int sched,
|
||||
long N, Fn&& one_rep) {
|
||||
for (int i = 0; i < g_cfg.warmup; ++i) (void)one_rep(); // M4
|
||||
|
||||
std::vector<double> ips, ovh;
|
||||
long ivcsw = 0, vcsw = 0;
|
||||
for (int i = 0; i < g_cfg.reps; ++i) {
|
||||
Sample s = one_rep();
|
||||
ips.push_back(s.items_per_sec);
|
||||
ovh.push_back(s.overhead_us);
|
||||
ivcsw += s.nivcsw;
|
||||
vcsw += s.nvcsw;
|
||||
}
|
||||
|
||||
const double med = percentile(ips, 0.5);
|
||||
const double q1 = percentile(ips, 0.25);
|
||||
const double q3 = percentile(ips, 0.75);
|
||||
const double iqr = med > 0 ? 100.0 * (q3 - q1) / med : 0.0;
|
||||
const double lo = *std::min_element(ips.begin(), ips.end());
|
||||
const double hi = *std::max_element(ips.begin(), ips.end());
|
||||
const double spread = med > 0 ? 100.0 * (hi - lo) / med : 0.0;
|
||||
const double ivcsw_per_item = static_cast<double>(ivcsw) / (double(N) * g_cfg.reps);
|
||||
const double vcsw_per_item = static_cast<double>(vcsw) / (double(N) * g_cfg.reps);
|
||||
|
||||
const std::string s = sched < 0 ? "tbb"
|
||||
: sched == 0 ? "priv"
|
||||
: std::to_string(sched);
|
||||
|
||||
std::fprintf(stderr, "%-10s %-5d %-8d %-6s %-8ld %-12.0f %-7.1f %-7.1f %-9.1f %-8.2f %-8.2f\n",
|
||||
topology, size, work_us, s.c_str(), N,
|
||||
med, iqr, spread, percentile(ovh, 0.5), ivcsw_per_item, vcsw_per_item);
|
||||
std::printf("%s,%d,%d,%s,%ld,%d,%.0f,%.0f,%.0f,%.2f,%.2f,%.2f,%.3f,%.3f\n",
|
||||
topology, size, work_us, s.c_str(), N, g_cfg.reps,
|
||||
med, lo, hi, iqr, spread, percentile(ovh, 0.5),
|
||||
ivcsw_per_item, vcsw_per_item);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
// ── argument parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
static std::vector<int> parse_int_list(const char* s) {
|
||||
std::vector<int> out;
|
||||
const char* p = s;
|
||||
while (*p) {
|
||||
char* end = nullptr;
|
||||
long v = std::strtol(p, &end, 10);
|
||||
if (end == p) break;
|
||||
out.push_back(static_cast<int>(v));
|
||||
p = end;
|
||||
while (*p == ',' || *p == ' ') ++p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool has_word(const std::string& csv, const char* word) {
|
||||
return csv.find(word) != std::string::npos;
|
||||
}
|
||||
|
||||
static void usage() {
|
||||
std::fprintf(stderr,
|
||||
"usage: bench_pipeline [options]\n"
|
||||
" --work=10,100,1000 per-node busy-work, microseconds\n"
|
||||
" --depths=1,2,4,8,16,32 chain depths\n"
|
||||
" --widths=1,2,3,4 fanout widths\n"
|
||||
" --pools=1,2,4,8,16,20 shared-pool thread counts\n"
|
||||
" --topos=chain,wide,diamond\n"
|
||||
" --modes=priv,pool,tbb\n"
|
||||
" --reps=5 measured repetitions per row\n"
|
||||
" --warmup=1 discarded repetitions per row\n"
|
||||
" --target-sec=0.30 aimed-for duration of one repetition\n"
|
||||
" --min-items=2000 sample-size floor\n"
|
||||
" --max-sec=3.0 per-repetition ceiling (overrides the floor)\n");
|
||||
}
|
||||
|
||||
static bool parse_args(int argc, char** argv) {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string a = argv[i];
|
||||
auto eq = a.find('=');
|
||||
std::string key = a.substr(0, eq);
|
||||
std::string val = eq == std::string::npos ? "" : a.substr(eq + 1);
|
||||
|
||||
if (key == "--help" || key == "-h") { usage(); std::exit(0); }
|
||||
else if (key == "--work") g_cfg.work_amts = parse_int_list(val.c_str());
|
||||
else if (key == "--depths") g_cfg.depths = parse_int_list(val.c_str());
|
||||
else if (key == "--widths") g_cfg.widths = parse_int_list(val.c_str());
|
||||
else if (key == "--pools") g_cfg.pool_sizes = parse_int_list(val.c_str());
|
||||
else if (key == "--reps") g_cfg.reps = std::atoi(val.c_str());
|
||||
else if (key == "--warmup") g_cfg.warmup = std::atoi(val.c_str());
|
||||
else if (key == "--target-sec") g_cfg.target_sec = std::atof(val.c_str());
|
||||
else if (key == "--min-items") g_cfg.min_items = std::atol(val.c_str());
|
||||
else if (key == "--max-sec") g_cfg.max_sec = std::atof(val.c_str());
|
||||
else if (key == "--topos") {
|
||||
g_cfg.do_chain = has_word(val, "chain");
|
||||
g_cfg.do_wide = has_word(val, "wide");
|
||||
g_cfg.do_diamond = has_word(val, "diamond");
|
||||
}
|
||||
else if (key == "--modes") {
|
||||
g_cfg.do_priv = has_word(val, "priv");
|
||||
g_cfg.do_pool = has_word(val, "pool");
|
||||
g_cfg.do_tbb = has_word(val, "tbb");
|
||||
}
|
||||
else { std::fprintf(stderr, "unknown option: %s\n", a.c_str()); usage(); return false; }
|
||||
}
|
||||
if (g_cfg.reps < 1) g_cfg.reps = 1;
|
||||
if (g_cfg.warmup < 0) g_cfg.warmup = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// `wide` is templated on W, so dispatch the runtime width through a switch.
|
||||
template<class F>
|
||||
static void with_width(int w, F&& f) {
|
||||
switch (w) {
|
||||
case 1: f(std::integral_constant<std::size_t, 1>{}); break;
|
||||
case 2: f(std::integral_constant<std::size_t, 2>{}); break;
|
||||
case 3: f(std::integral_constant<std::size_t, 3>{}); break;
|
||||
case 4: f(std::integral_constant<std::size_t, 4>{}); break;
|
||||
default:
|
||||
std::fprintf(stderr, "width %d not instantiated (1..4 only)\n", w);
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main() {
|
||||
const int work_amts[] = {10, 100, 1000};
|
||||
const int pool_sizes[] = {1, 2, 4};
|
||||
int main(int argc, char** argv) {
|
||||
// A rejected option must fail loudly: a harness driver that silently got
|
||||
// no CSV back is worse than one that stops.
|
||||
if (!parse_args(argc, argv)) return 2;
|
||||
|
||||
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");
|
||||
char cfg[192];
|
||||
std::snprintf(cfg, sizeof cfg,
|
||||
"reps=%d warmup=%d target_sec=%.2f min_items=%ld max_sec=%.1f",
|
||||
g_cfg.reps, g_cfg.warmup, g_cfg.target_sec,
|
||||
g_cfg.min_items, g_cfg.max_sec);
|
||||
bench::print_environment(cfg);
|
||||
|
||||
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);
|
||||
};
|
||||
std::fprintf(stderr, "\n%-10s %-5s %-8s %-6s %-8s %-12s %-7s %-7s %-9s %-8s %-8s\n",
|
||||
"topology", "size", "work_us", "sched", "items", "items/sec",
|
||||
"iqr%", "range%", "ovh_us", "ivcsw/it", "vcsw/it");
|
||||
std::fprintf(stderr, "%s\n", std::string(104, '-').c_str());
|
||||
std::printf("topology,size,work_us,threads,items,reps,items_per_sec,"
|
||||
"items_per_sec_min,items_per_sec_max,iqr_pct,range_pct,"
|
||||
"overhead_us_per_item,ivcsw_per_item,vcsw_per_item\n");
|
||||
|
||||
for (int w : work_amts) {
|
||||
for (int w : g_cfg.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));
|
||||
if (g_cfg.do_priv) {
|
||||
std::fprintf(stderr, "\n── work_us=%-4d private pools ──────────────────────\n", w);
|
||||
if (g_cfg.do_chain)
|
||||
for (int d : g_cfg.depths) {
|
||||
long N = pick_items(w, d, d);
|
||||
run_row("chain", d, w, 0, N, [&] { return bench_chain(d, w, N); });
|
||||
}
|
||||
if (g_cfg.do_wide)
|
||||
for (int wd : g_cfg.widths)
|
||||
with_width(wd, [&](auto W) {
|
||||
long N = pick_items(w, W.value, W.value);
|
||||
run_row("wide", static_cast<int>(W.value), w, 0, N,
|
||||
[&] { return bench_wide<W.value>(w, N); });
|
||||
});
|
||||
if (g_cfg.do_diamond) {
|
||||
long N = pick_items(w, 4, 4);
|
||||
run_row("diamond", 4, w, 0, N, [&] { return bench_diamond(w, N); });
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
if (g_cfg.do_pool) {
|
||||
for (int pt : g_cfg.pool_sizes) {
|
||||
std::fprintf(stderr, "\n── work_us=%-4d shared pool (%d thread%s) ───────────\n",
|
||||
w, pt, pt == 1 ? "" : "s");
|
||||
if (g_cfg.do_chain)
|
||||
for (int d : g_cfg.depths) {
|
||||
long N = pick_items(w, d, pt);
|
||||
run_row("chain", d, w, pt, N,
|
||||
[&] { return bench_chain_pool(d, w, pt, N); });
|
||||
}
|
||||
if (g_cfg.do_wide)
|
||||
for (int wd : g_cfg.widths)
|
||||
with_width(wd, [&](auto W) {
|
||||
long N = pick_items(w, W.value, pt);
|
||||
run_row("wide", static_cast<int>(W.value), w, pt, N,
|
||||
[&] { return bench_wide_pool<W.value>(w, pt, N); });
|
||||
});
|
||||
if (g_cfg.do_diamond) {
|
||||
long N = pick_items(w, 4, pt);
|
||||
run_row("diamond", 4, w, pt, N,
|
||||
[&] { return bench_diamond_pool(w, pt, N); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#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));
|
||||
if (g_cfg.do_tbb) {
|
||||
std::fprintf(stderr, "\n── work_us=%-4d TBB flow graph ─────────────────────\n", w);
|
||||
if (g_cfg.do_chain)
|
||||
for (int d : g_cfg.depths) {
|
||||
long N = pick_items(w, d, d);
|
||||
run_row("chain_tbb", d, w, -1, N,
|
||||
[&] { return bench_chain_tbb(d, w, N); });
|
||||
}
|
||||
if (g_cfg.do_wide)
|
||||
for (int wd : g_cfg.widths)
|
||||
with_width(wd, [&](auto W) {
|
||||
long N = pick_items(w, W.value, W.value);
|
||||
run_row("wide_tbb", static_cast<int>(W.value), w, -1, N,
|
||||
[&] { return bench_wide_tbb<W.value>(w, N); });
|
||||
});
|
||||
if (g_cfg.do_diamond) {
|
||||
long N = pick_items(w, 4, 4);
|
||||
run_row("diamond_tbb", 4, w, -1, N,
|
||||
[&] { return bench_diamond_tbb(w, N); });
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Channels
|
||||
|
||||
A `Channel<T>` is a lock-free SPSC (single-producer, single-consumer) ring buffer with atomic wait/notify.
|
||||
|
||||
## Semantics
|
||||
|
||||
- **Bounded**: fixed capacity set at construction. Default is 5 items.
|
||||
- **Backpressure**: when full, `push()` throws `ChannelOverflowError` immediately — no blocking, no spin.
|
||||
- **Blocking consumer**: `pop()` blocks until an item is available or the channel is disabled.
|
||||
- **Disable**: `channel.disable()` stops accepting pushes and unblocks any waiting `pop()` with `ChannelClosedError`.
|
||||
|
||||
## Storage policy
|
||||
|
||||
Small trivially-copyable types (≤ 8 bytes) are stored by value. Larger types are heap-allocated and passed via `shared_ptr<const T>` — one allocation per push, zero-copy fan-out:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/04_storage_policy/main.cpp:storage_policy_spec"
|
||||
```
|
||||
|
||||
Specialize `kpn::ChannelDataSize<T>` for accurate bandwidth reporting on heap-owning types:
|
||||
|
||||
```cpp
|
||||
template<>
|
||||
struct kpn::ChannelDataSize<cv::Mat> {
|
||||
static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
|
||||
};
|
||||
```
|
||||
|
||||
## Named ports
|
||||
|
||||
`in<"name">` and `out<"name">` tag nodes for readable wiring:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/02_named_ports/main.cpp:named_port_creation"
|
||||
```
|
||||
|
||||
Named ports are checked at compile time — a typo in a port name is a compile error.
|
||||
|
||||
## Capacity tuning
|
||||
|
||||
Set capacity per node at construction:
|
||||
|
||||
```cpp
|
||||
auto node = make_node<my_func>(/*capacity=*/20);
|
||||
```
|
||||
|
||||
Capacity is rounded up internally to the next power of two. Monitor fill levels via diagnostics to tune for your workload — a too-small capacity causes overflows; a too-large one wastes memory and hides producer/consumer speed mismatches.
|
||||
|
||||
## Spin count
|
||||
|
||||
`Channel` spins for up to ~4 µs (200 `pause` hints at ~20 ns each on x86) before sleeping on a futex. Set to 0 for power-constrained or predominantly-idle pipelines:
|
||||
|
||||
```cpp
|
||||
Channel<int> ch(/*capacity=*/5, /*spin_count=*/0);
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# Error Handling & Events
|
||||
|
||||
KPN++ provides three complementary layers for observing and reacting to failures.
|
||||
|
||||
---
|
||||
|
||||
## 1. Per-node error handler
|
||||
|
||||
Called when a node's function throws an unhandled exception. Return `true` to skip the failed invocation and keep running; `false` to stop the node.
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/15_node_error_handler/main.cpp:error_handler"
|
||||
```
|
||||
|
||||
When a node stops (either from `false` return or no handler installed), it:
|
||||
|
||||
1. Disables its **input** channels — upstream stops pushing into dead queues.
|
||||
2. Disables its **output** channels — downstream nodes receive `ChannelClosedError` on their next pop, propagating the shutdown naturally through the graph.
|
||||
|
||||
---
|
||||
|
||||
## 2. Per-node overflow callback
|
||||
|
||||
Fired with a timestamp each time an output push is dropped because the channel is full. The node name is known at registration so it is not included — keeping the callback zero-overhead when unused.
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/16_event_callbacks/main.cpp:per_node_callback"
|
||||
```
|
||||
|
||||
!!! note
|
||||
The callback is purely informational — the node always continues after an overflow. To stop the node on overflow, call `node.stop()` from inside the callback.
|
||||
|
||||
A matching `set_closed_callback()` fires (also with just a timestamp) when the node stops due to a closed upstream channel:
|
||||
|
||||
```cpp
|
||||
node.set_closed_callback([](std::chrono::steady_clock::time_point ts) {
|
||||
std::cerr << "node stopped at t=" << ts.time_since_epoch().count() << '\n';
|
||||
});
|
||||
```
|
||||
|
||||
Each node holds two callback slots per event type — one user-set (registered above) and one injected by the network (see below). Both fire independently.
|
||||
|
||||
---
|
||||
|
||||
## 3. Network-level event handler
|
||||
|
||||
One callback for the whole network. Receives the node name (captured in a closure by the network at `build()` / `start()`), a `NodeEvent`, and a timestamp:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
|
||||
```
|
||||
|
||||
`NodeEvent` values:
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `NodeEvent::Overflow` | An output push was dropped (channel full) |
|
||||
| `NodeEvent::Closed` | The node stopped (crash or upstream close cascade) |
|
||||
|
||||
The network handler and any per-node callbacks are **independent** — both fire when set.
|
||||
|
||||
---
|
||||
|
||||
## Complete example
|
||||
|
||||
`examples/16_event_callbacks/main.cpp` shows a fast producer overflowing a slow consumer, with both a per-node overflow callback and a network-level event handler active simultaneously.
|
||||
|
||||
Node functions:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/16_event_callbacks/main.cpp:node_fns"
|
||||
```
|
||||
|
||||
Per-node overflow callback:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/16_event_callbacks/main.cpp:per_node_callback"
|
||||
```
|
||||
|
||||
Network-level event handler:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
# Examples
|
||||
|
||||
All C++ examples are built by default and registered as CTest smoke tests. Run them all with:
|
||||
|
||||
```bash
|
||||
ctest --test-dir build -L examples
|
||||
```
|
||||
|
||||
## Index
|
||||
|
||||
| Example | What it shows |
|
||||
|---|---|
|
||||
| `01_hello_pipeline` | Linear pipeline, index-based port wiring |
|
||||
| `02_named_ports` | `in<>`/`out<>` name tags, named port access |
|
||||
| `03_multi_output` | Tuple-returning node, per-element routing |
|
||||
| `04_storage_policy` | `channel_storage_policy` specialisation |
|
||||
| `05_error_handling` | Diagnostics handler, overflow channel stats |
|
||||
| `06_watchdog` | Watchdog interval, stall detection |
|
||||
| `10_static_hello_pipeline` | `StaticNetwork` + `make_network()` |
|
||||
| `11_static_fanout` | `StaticNetwork` with `FanoutNode` |
|
||||
| `15_node_error_handler` | `set_error_handler()` — skip or stop on exception |
|
||||
| `16_event_callbacks` | `set_overflow_callback()`, `set_event_handler()` |
|
||||
|
||||
## OpenCV examples (optional)
|
||||
|
||||
Built only when OpenCV ≥ 4 is found:
|
||||
|
||||
| Example | What it shows |
|
||||
|---|---|
|
||||
| `09_opencv_cellshade` | Real-time cell-shading on webcam; `MainThreadNode` for display |
|
||||
| `12_static_cellshade` | Same pipeline as a `StaticNetwork` |
|
||||
| `13_debug_cellshade` | Web debug UI overlay on the cell-shading pipeline |
|
||||
|
||||
Run the cell-shading example:
|
||||
|
||||
```bash
|
||||
./build/examples/09_opencv_cellshade
|
||||
# Press 'q' or close the window to stop.
|
||||
# Falls back to an animated synthetic pattern if no webcam is found.
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# Fan-out & Routing
|
||||
|
||||
## FanoutNode
|
||||
|
||||
Reads one item and pushes a copy to each of N output channels. All downstream nodes receive every item.
|
||||
|
||||
```cpp
|
||||
auto fan = make_fanout<Image, 2>(/*capacity=*/8);
|
||||
|
||||
net.connect("src", src.output<0>(), "fan", fan.input<0>())
|
||||
.connect("fan", fan.output<0>(), "nodeA", nodeA.input<0>())
|
||||
.connect("fan", fan.output<1>(), "nodeB", nodeB.input<0>());
|
||||
```
|
||||
|
||||
If one downstream channel overflows, that output drops the item independently — the other outputs are unaffected.
|
||||
|
||||
See `examples/11_static_fanout`.
|
||||
|
||||
## RouterNode
|
||||
|
||||
Reads one item and pushes it to exactly one of N outputs, chosen by a selector function:
|
||||
|
||||
```cpp
|
||||
auto router = make_router<Frame, 3>(
|
||||
[](const Frame& f) -> std::size_t { return f.stream_id % 3; });
|
||||
|
||||
net.connect("src", src.output<0>(), "router", router.input<0>())
|
||||
.connect("router", router.output<0>(), "nodeA", nodeA.input<0>())
|
||||
.connect("router", router.output<1>(), "nodeB", nodeB.input<0>())
|
||||
.connect("router", router.output<2>(), "nodeC", nodeC.input<0>());
|
||||
```
|
||||
|
||||
If the selector returns `>= N` the item is silently dropped.
|
||||
|
||||
## FilterNode
|
||||
|
||||
Reads one item and passes it downstream only when a predicate returns `true`:
|
||||
|
||||
```cpp
|
||||
auto filt = make_filter<Frame>([](const Frame& f) { return f.valid; });
|
||||
|
||||
net.connect("src", src.output<0>(), "filt", filt.input<0>())
|
||||
.connect("filt", filt.output<0>(), "dst", dst.input<0>());
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# Getting Started
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Version | Notes |
|
||||
|---|---|---|
|
||||
| CMake | ≥ 3.21 | |
|
||||
| C++ compiler | GCC ≥ 11, Clang ≥ 13 | C++20 required |
|
||||
| nanobind | ≥ 2.1 | auto-fetched; Python ≥ 3.8 |
|
||||
| Catch2 | v3 | auto-fetched for tests |
|
||||
| OpenCV | ≥ 4 | optional; only for examples 09/12/13 |
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cmake -B build # core + tests + C++ examples
|
||||
cmake --build build --parallel
|
||||
ctest --test-dir build # run all tests including example smoke tests
|
||||
```
|
||||
|
||||
Enable Python bindings:
|
||||
|
||||
```bash
|
||||
cmake -B build -DKPN_BUILD_PYTHON=ON
|
||||
cmake --build build --parallel
|
||||
```
|
||||
|
||||
Skip examples:
|
||||
|
||||
```bash
|
||||
cmake -B build -DKPN_BUILD_EXAMPLES=OFF
|
||||
```
|
||||
|
||||
## Your first pipeline
|
||||
|
||||
Three functions — source, transform, sink — wired into a `Network`:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/01_hello_pipeline/main.cpp:basic_node_fns"
|
||||
```
|
||||
|
||||
Create nodes, connect them, build and run:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
|
||||
```
|
||||
|
||||
That's it. Types are inferred from function signatures. The channel between `src` and `dbl` carries `int`; the channel between `dbl` and `prn` also carries `int`. A type mismatch is a compile error.
|
||||
|
||||
## Named ports
|
||||
|
||||
For nodes with multiple inputs or outputs, name the ports for clarity:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/02_named_ports/main.cpp:named_port_creation"
|
||||
```
|
||||
|
||||
Wire by name instead of index:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/02_named_ports/main.cpp:named_port_network"
|
||||
```
|
||||
|
||||
## Multi-output nodes
|
||||
|
||||
Return a `std::tuple` to fan out to multiple downstream nodes:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/03_multi_output/main.cpp:multi_output_fn"
|
||||
```
|
||||
|
||||
Wire each tuple element to its own downstream node:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/03_multi_output/main.cpp:fanout_network"
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# KPN++
|
||||
|
||||
A C++20 [Kahn Process Network](https://en.wikipedia.org/wiki/Kahn_process_networks) library. Each node wraps a plain function and runs concurrently, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind.
|
||||
|
||||
---
|
||||
|
||||
## Why KPN++?
|
||||
|
||||
- **Zero boilerplate** — wrap any callable as a node; types flow automatically from the function signature
|
||||
- **Bounded channels** — backpressure is structural, not bolted on
|
||||
- **Observable** — per-node and network-level callbacks for overflow and stop events; diagnostics snapshots; optional web UI
|
||||
- **Composable** — `Network` for runtime wiring, `StaticNetwork` for compile-time topology with zero overhead
|
||||
|
||||
---
|
||||
|
||||
## Quick example
|
||||
|
||||
```cpp
|
||||
#include <kpn/kpn.hpp>
|
||||
using namespace kpn;
|
||||
|
||||
--8<-- "examples/01_hello_pipeline/main.cpp:basic_node_fns"
|
||||
|
||||
int main() {
|
||||
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Install & build
|
||||
|
||||
```bash
|
||||
cmake -B build
|
||||
cmake --build build --parallel
|
||||
ctest --test-dir build # unit tests + example smoke tests
|
||||
```
|
||||
|
||||
See [Getting Started](getting-started.md) for full build options.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Networks
|
||||
|
||||
A `Network` wires nodes together at runtime using a builder chain.
|
||||
|
||||
## Building a network
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
|
||||
```
|
||||
|
||||
The builder chain:
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `.add(name, node)` | Register a node; assigns its name |
|
||||
| `.connect(src, port, dst, port)` | Wire one output port to one input port |
|
||||
| `.build()` | Compute topological order; inject network callbacks |
|
||||
| `.start()` | Start nodes in topological order |
|
||||
| `.stop()` | Stop all nodes immediately |
|
||||
| `.shutdown()` | Graceful drain: stop sources first, wait for channels to empty, then stop downstream |
|
||||
|
||||
## Port access
|
||||
|
||||
Ports are accessed by index or by name:
|
||||
|
||||
```cpp
|
||||
// By index
|
||||
net.connect("src", src.output<0>(), "dst", dst.input<0>());
|
||||
|
||||
// By name (requires named ports)
|
||||
--8<-- "examples/02_named_ports/main.cpp:named_port_network"
|
||||
```
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Install a diagnostics handler to receive periodic snapshots of every node and channel:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/05_error_handling/main.cpp:diagnostics_handler"
|
||||
```
|
||||
|
||||
Or print a full report at any time:
|
||||
|
||||
```cpp
|
||||
net.print_diagnostics(); // writes to stderr by default
|
||||
net.print_diagnostics(std::cout);
|
||||
```
|
||||
|
||||
## Network-level event handler
|
||||
|
||||
Observe overflow and node-stop events across the entire network in one place:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
|
||||
```
|
||||
|
||||
`NodeEvent` is either `NodeEvent::Overflow` (item dropped on full channel) or `NodeEvent::Closed` (node stopped due to crash or closed upstream channel). See [Error Handling & Events](error-handling.md).
|
||||
|
||||
## Shutdown
|
||||
|
||||
`net.stop()` halts immediately — all nodes stop in reverse topological order.
|
||||
|
||||
`net.shutdown()` drains gracefully: source nodes stop first; their output channels are polled until empty; then the next layer stops, and so on. This ensures no items are lost if downstream nodes are still consuming.
|
||||
|
||||
## StaticNetwork
|
||||
|
||||
For zero-overhead compile-time topology, see [Static Networks](static-network.md).
|
||||
@@ -0,0 +1,81 @@
|
||||
# Nodes
|
||||
|
||||
A node wraps any callable. Its input types are inferred from the function's parameter list; its output types from the return type.
|
||||
|
||||
## Node types
|
||||
|
||||
| Type | Thread model | Use case |
|
||||
|---|---|---|
|
||||
| `Node<Func>` | Dedicated thread per node | Default — simplest, most isolated |
|
||||
| `PoolNode<Func>` | Shared `ThreadPool` | Many nodes, resource-bounded execution |
|
||||
| `InterruptNode<Func>` | Event-driven, no thread | Camera frame ready, timer tick, socket |
|
||||
| `FanoutNode<T, N>` | Dedicated thread | Broadcast one item to N outputs |
|
||||
| `RouterNode<T, N>` | Dedicated thread | Route one item to one of N outputs |
|
||||
| `FilterNode<T>` | Dedicated thread | Pass items matching a predicate |
|
||||
|
||||
## Creating nodes
|
||||
|
||||
All node types are created via factory functions that infer types from the callable:
|
||||
|
||||
```cpp
|
||||
// Free function — simplest case
|
||||
auto node = make_node<my_func>();
|
||||
|
||||
// Stateful functor (operator() is the function)
|
||||
MyProcessor proc;
|
||||
auto node = make_node(proc);
|
||||
|
||||
// Pool node — shares a ThreadPool with other nodes
|
||||
auto pool = std::make_shared<ThreadPool>(4);
|
||||
auto node = make_pool_node<my_func>(pool);
|
||||
|
||||
// Interrupt node — triggered externally
|
||||
auto sched = std::make_shared<ThreadPool>(2);
|
||||
auto node = make_interrupt_node<produce_frame>(sched, out<"frame">{});
|
||||
camera_sdk.on_frame_ready(node.get_trigger());
|
||||
```
|
||||
|
||||
## Channel capacity
|
||||
|
||||
Each node's input FIFO has a configurable capacity (default 5):
|
||||
|
||||
```cpp
|
||||
auto node = make_node<my_func>(/*capacity=*/20);
|
||||
auto node = make_pool_node<my_func>(pool, /*capacity=*/20);
|
||||
```
|
||||
|
||||
When an upstream push would exceed capacity, `ChannelOverflowError` is thrown and the item is dropped. See [Error Handling & Events](error-handling.md) to observe and react to this.
|
||||
|
||||
## Source nodes
|
||||
|
||||
A node with no inputs is a source. It self-submits immediately on `start()` and re-submits after each execution:
|
||||
|
||||
```cpp
|
||||
static int produce() {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
return ++counter;
|
||||
}
|
||||
auto src = make_node<produce>();
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Source nodes must sleep or yield to avoid overflowing their output channel. The channel capacity provides the only bound.
|
||||
|
||||
## Sink nodes
|
||||
|
||||
A node with a `void` return is a sink — it consumes items without producing output:
|
||||
|
||||
```cpp
|
||||
static void print_it(int x) { std::cout << x << '\n'; }
|
||||
auto snk = make_node<print_it>();
|
||||
```
|
||||
|
||||
## Error handler
|
||||
|
||||
When a node's function throws an unhandled exception, the default behaviour is to stop the node (disabling its channels so the shutdown cascades downstream). Install a handler to override:
|
||||
|
||||
```cpp
|
||||
--8<-- "examples/15_node_error_handler/main.cpp:error_handler"
|
||||
```
|
||||
|
||||
See [Error Handling & Events](error-handling.md) for the full picture.
|
||||
@@ -0,0 +1,3 @@
|
||||
mkdocs>=1.5
|
||||
mkdocs-material>=9.5
|
||||
pymdown-extensions>=10.0
|
||||
@@ -0,0 +1,42 @@
|
||||
# Shared Resources
|
||||
|
||||
`SharedResource<T>` arbitrates exclusive access to a resource (ONNX session, CUDA stream, serial port) across multiple nodes using a priority-based waiter queue with starvation prevention.
|
||||
|
||||
## Usage
|
||||
|
||||
```cpp
|
||||
#include <kpn/shared_resource.hpp>
|
||||
using namespace kpn;
|
||||
|
||||
SharedResource<OnnxSession> model(session_args...);
|
||||
|
||||
static cv::Mat run_inference(cv::Mat frame) {
|
||||
// Acquires the model; releases automatically on scope exit.
|
||||
auto guard = model.acquire_balanced(in_channel, out_channel);
|
||||
return guard->Run(frame);
|
||||
}
|
||||
```
|
||||
|
||||
## Acquire modes
|
||||
|
||||
| Method | Priority |
|
||||
|---|---|
|
||||
| `acquire()` | Equal (fair FIFO) |
|
||||
| `acquire(fn)` | Custom — `fn()` returns `float` in `[0, 1]` |
|
||||
| `acquire_balanced(in_ch, out_ch)` | `input_fill × output_headroom` — highest urgency wins |
|
||||
|
||||
`acquire_balanced` favours nodes with full input queues and empty output queues — the node that has the most work to do and nowhere to stall wins the resource next.
|
||||
|
||||
## Starvation prevention
|
||||
|
||||
Each waiter's effective score grows with elapsed wait time (`0.05` per second by default), ensuring a low-priority node eventually gets served regardless of how frequently higher-priority nodes compete.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Register with the network for snapshot reporting:
|
||||
|
||||
```cpp
|
||||
net.register_resource("model", &model);
|
||||
```
|
||||
|
||||
The diagnostics table then shows acquisition count, mean wait time, and current waiter count.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Static Networks
|
||||
|
||||
`StaticNetwork` encodes the entire topology at compile time using a `make_network()` builder. Nodes and channel types are verified statically with zero runtime overhead.
|
||||
|
||||
## Usage
|
||||
|
||||
```cpp
|
||||
#include <kpn/kpn.hpp>
|
||||
using namespace kpn;
|
||||
|
||||
static int produce() { return 42; }
|
||||
static int double_it(int x) { return x * 2; }
|
||||
static void print_it(int x) { std::cout << x << '\n'; }
|
||||
|
||||
int main() {
|
||||
auto src = make_node<produce> ();
|
||||
auto dbl = make_node<double_it>();
|
||||
auto prn = make_node<print_it> ();
|
||||
|
||||
auto net = make_network(
|
||||
edge(src, src.output<0>(), dbl, dbl.input<0>()),
|
||||
edge(dbl, dbl.output<0>(), prn, prn.input<0>())
|
||||
);
|
||||
|
||||
net.set_event_handler([](std::string_view name, NodeEvent ev, auto ts) {
|
||||
// same API as Network
|
||||
});
|
||||
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
net.stop();
|
||||
}
|
||||
```
|
||||
|
||||
See `examples/10_static_hello_pipeline` and `examples/11_static_fanout`.
|
||||
|
||||
## When to use
|
||||
|
||||
| | `Network` | `StaticNetwork` |
|
||||
|---|---|---|
|
||||
| Topology known at | Runtime | Compile time |
|
||||
| Type checking | Runtime (`dynamic_cast`) | Compile time |
|
||||
| Overhead | Minimal | Zero |
|
||||
| Flexibility | Add nodes dynamically | Fixed at compile time |
|
||||
|
||||
For most applications `Network` is sufficient. Use `StaticNetwork` when you need the absolute minimum overhead or want compile-time topology verification.
|
||||
@@ -7,20 +7,20 @@
|
||||
//
|
||||
// [produce] --int--> [double_it] --int--> [print_it]
|
||||
|
||||
// [snippet: basic_node_fns]
|
||||
// --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'; }
|
||||
// [/snippet: basic_node_fns]
|
||||
// --8<-- [end:basic_node_fns]
|
||||
|
||||
int main() {
|
||||
using namespace kpn;
|
||||
|
||||
// [snippet: index_only_nodes]
|
||||
// --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);
|
||||
// [/snippet: index_only_nodes]
|
||||
// --8<-- [end:index_only_nodes]
|
||||
|
||||
// Wire channels
|
||||
auto& dbl_in = dbl.input_channel<0>();
|
||||
@@ -28,7 +28,7 @@ int main() {
|
||||
src.set_output_channel<0>(&dbl_in);
|
||||
dbl.set_output_channel<0>(&sink_in);
|
||||
|
||||
// [snippet: network_build]
|
||||
// --8<-- [start:network_build]
|
||||
Network net;
|
||||
net.add("src", src)
|
||||
.add("dbl", dbl)
|
||||
@@ -40,5 +40,5 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
net.stop();
|
||||
// [/snippet: network_build]
|
||||
// --8<-- [end:network_build]
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ static void report(int count, std::vector<std::string> words) {
|
||||
int main() {
|
||||
using namespace kpn;
|
||||
|
||||
// [snippet: named_port_creation]
|
||||
// --8<-- [start:named_port_creation]
|
||||
// tokenise: no inputs, one named output "words"
|
||||
auto tok = make_node<tokenise>(out<"words">{}, 4);
|
||||
|
||||
@@ -63,9 +63,9 @@ int main() {
|
||||
|
||||
// report: two named inputs
|
||||
auto snk = make_node<report>(in<"count", "words">{}, 4);
|
||||
// [/snippet: named_port_creation]
|
||||
// --8<-- [end:named_port_creation]
|
||||
|
||||
// [snippet: named_port_network]
|
||||
// --8<-- [start:named_port_network]
|
||||
Network net;
|
||||
net.add("tok", tok)
|
||||
.add("cnt", cnt)
|
||||
@@ -78,5 +78,5 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
net.stop();
|
||||
// [/snippet: named_port_network]
|
||||
// --8<-- [end:named_port_network]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ static std::string generate() {
|
||||
return pairs[gen_index++ % 5];
|
||||
}
|
||||
|
||||
// [snippet: multi_output_fn]
|
||||
// --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 +41,7 @@ static std::tuple<std::string, std::string> parse(std::string kv) {
|
||||
if (sep == std::string::npos) return {kv, ""};
|
||||
return {kv.substr(0, sep), kv.substr(sep + 1)};
|
||||
}
|
||||
// [/snippet: multi_output_fn]
|
||||
// --8<-- [end:multi_output_fn]
|
||||
|
||||
static void print_key(std::string key) {
|
||||
std::cout << "KEY → " << key << '\n';
|
||||
@@ -56,7 +56,7 @@ static void print_value(std::string value) {
|
||||
int main() {
|
||||
using namespace kpn;
|
||||
|
||||
// [snippet: fanout_network]
|
||||
// --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 +75,5 @@ int main() {
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(600));
|
||||
net.stop();
|
||||
// [/snippet: fanout_network]
|
||||
// --8<-- [end:fanout_network]
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ struct Tag {
|
||||
int value = 0;
|
||||
};
|
||||
|
||||
// [snippet: storage_policy_spec]
|
||||
// --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;
|
||||
};
|
||||
// [/snippet: storage_policy_spec]
|
||||
// --8<-- [end:storage_policy_spec]
|
||||
|
||||
// ── Node functions ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ int main() {
|
||||
|
||||
Network net;
|
||||
|
||||
// [snippet: diagnostics_handler]
|
||||
// --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 +58,7 @@ int main() {
|
||||
<< "overflows=" << c.overflows;
|
||||
std::cout << '\n';
|
||||
});
|
||||
// [/snippet: diagnostics_handler]
|
||||
// --8<-- [end:diagnostics_handler]
|
||||
|
||||
net.set_watchdog_interval(std::chrono::milliseconds(200));
|
||||
|
||||
|
||||
@@ -30,3 +30,8 @@ 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,38 +1,55 @@
|
||||
"""
|
||||
08_python_subport — tap a C++ node's output from Python using net.read().
|
||||
08_python_subport — drive a *Python* node from Python via write()/read() taps.
|
||||
|
||||
Graph:
|
||||
[ProduceNode] --int--> [DoubleItNode] --int--> (tapped by net.read())
|
||||
(fed by net.write()) --int--> [py_triple] --int--> (tapped by net.read())
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
sys.path.insert(0, "build/python")
|
||||
sys.path.insert(0, "build/python") # for `python examples/.../example.py` from repo root
|
||||
|
||||
import kpn_python as kpn
|
||||
|
||||
|
||||
def py_triple(x: int) -> int:
|
||||
return x * 3
|
||||
|
||||
|
||||
net = kpn.Network()
|
||||
|
||||
net.add("src", kpn.make_produce())
|
||||
net.add("dbl", kpn.make_double_it())
|
||||
# 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.connect("src", 0, "dbl", 0)
|
||||
net.build()
|
||||
net.start()
|
||||
|
||||
# Collect a few values from DoubleItNode's output via Python tap
|
||||
# Push values in from Python and read the Python node's results back out.
|
||||
inputs = [1, 2, 7, 10, 100]
|
||||
results = []
|
||||
for _ in range(5):
|
||||
val = net.read("dbl", 0)
|
||||
results.append(val)
|
||||
for v in inputs:
|
||||
net.write("py", 0, v) # Python -> py_triple input
|
||||
results.append(net.read("py", 0)) # py_triple output -> Python
|
||||
|
||||
net.stop()
|
||||
|
||||
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)")
|
||||
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
|
||||
|
||||
@@ -38,7 +38,7 @@ static cv::Mat make_gradient(int W, int H) {
|
||||
|
||||
// ── Pipeline functions ────────────────────────────────────────────────────────
|
||||
|
||||
// [snippet: capture_fn]
|
||||
// --8<-- [start:capture_fn]
|
||||
static std::tuple<cv::Mat, cv::Mat> capture() {
|
||||
constexpr int W = 640, H = 480;
|
||||
static cv::VideoCapture cap;
|
||||
@@ -75,7 +75,7 @@ static std::tuple<cv::Mat, cv::Mat> capture() {
|
||||
}
|
||||
return {frame.clone(), frame.clone()};
|
||||
}
|
||||
// [/snippet: capture_fn]
|
||||
// --8<-- [end:capture_fn]
|
||||
|
||||
static cv::Mat to_gray(cv::Mat bgr) {
|
||||
cv::Mat gray;
|
||||
@@ -120,7 +120,7 @@ static std::tuple<cv::Mat, cv::Mat> composite(cv::Mat edge_mask, cv::Mat colour)
|
||||
// The constructor opens both windows on the main thread (Wayland requirement).
|
||||
// operator() is called by step() whenever both channels have a frame ready.
|
||||
|
||||
// [snippet: display_node]
|
||||
// --8<-- [start:display_node]
|
||||
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
|
||||
kpn::in<"composite", "edges">,
|
||||
cv::Mat, cv::Mat> {
|
||||
@@ -150,14 +150,14 @@ private:
|
||||
catch (const cv::Exception&) { return false; }
|
||||
}
|
||||
};
|
||||
// [/snippet: display_node]
|
||||
// --8<-- [end:display_node]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main() {
|
||||
using namespace kpn;
|
||||
|
||||
// [snippet: opencv_network]
|
||||
// --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,7 +182,7 @@ int main() {
|
||||
.connect("comp", comp.template output<"result">(), "display", disp.template input<"composite">())
|
||||
.connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">())
|
||||
.build();
|
||||
// [/snippet: opencv_network]
|
||||
// --8<-- [end:opencv_network]
|
||||
|
||||
net.set_watchdog_interval(std::chrono::milliseconds(5000));
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
@@ -192,7 +192,7 @@ int main() {
|
||||
std::cout << "Cell-shading pipeline running. Press 'q' to stop.\n";
|
||||
std::cout << "Web debug UI: http://localhost:9090\n";
|
||||
|
||||
// [snippet: main_thread_step]
|
||||
// --8<-- [start:main_thread_step]
|
||||
net.start();
|
||||
|
||||
// Main thread drives display — imshow/waitKey stay on the GUI thread.
|
||||
@@ -201,6 +201,6 @@ int main() {
|
||||
cv::waitKey(8); // yield event loop when no frame ready
|
||||
|
||||
net.stop();
|
||||
// [/snippet: main_thread_step]
|
||||
// --8<-- [end:main_thread_step]
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ int main() {
|
||||
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) {
|
||||
@@ -53,6 +54,7 @@ int main() {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// --8<-- [end:error_handler]
|
||||
|
||||
Network net;
|
||||
net.add("source", src)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Example 16 — Event Callbacks: overflow and node-stopped signals
|
||||
//
|
||||
// Two complementary observation mechanisms:
|
||||
//
|
||||
// 1. Per-node overflow callback set_overflow_callback()
|
||||
// Fired (with a timestamp) when a node's output channel is full and an
|
||||
// item is dropped. Useful for targeted monitoring of a specific node.
|
||||
//
|
||||
// 2. Network-level event handler net.set_event_handler()
|
||||
// Aggregate callback covering every node: receives the node name, a
|
||||
// NodeEvent (Overflow or Closed), and a timestamp. Register once and
|
||||
// observe the whole network.
|
||||
//
|
||||
// Pipeline: [fast_source] --int--> [slow_sink]
|
||||
//
|
||||
// fast_source produces at ~500 items/s; slow_sink consumes at ~20 items/s.
|
||||
// The channel capacity is 3, so overflows appear within milliseconds.
|
||||
|
||||
#include <kpn/kpn.hpp>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
using namespace kpn;
|
||||
using namespace std::chrono;
|
||||
|
||||
// ── Node functions ────────────────────────────────────────────────────────────
|
||||
|
||||
// --8<-- [start:node_fns]
|
||||
static std::atomic<int> g_seq{0};
|
||||
|
||||
static int fast_source() {
|
||||
std::this_thread::sleep_for(milliseconds(2)); // ~500/s
|
||||
return g_seq.fetch_add(1);
|
||||
}
|
||||
|
||||
static void slow_sink(int x) {
|
||||
std::this_thread::sleep_for(milliseconds(50)); // ~20/s
|
||||
std::cout << " consumed: " << x << '\n';
|
||||
}
|
||||
// --8<-- [end:node_fns]
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main() {
|
||||
auto src = make_node<fast_source>(/*capacity=*/3);
|
||||
auto snk = make_node<slow_sink> (/*capacity=*/3);
|
||||
|
||||
// --8<-- [start:per_node_callback]
|
||||
// Per-node overflow callback — no node name needed, known at registration.
|
||||
std::atomic<int> overflow_count{0};
|
||||
src.set_overflow_callback([&](steady_clock::time_point ts) {
|
||||
auto ms = duration_cast<milliseconds>(ts.time_since_epoch()).count();
|
||||
std::cerr << "[overflow] fast_source at t=" << ms << "ms\n";
|
||||
overflow_count.fetch_add(1);
|
||||
});
|
||||
// --8<-- [end:per_node_callback]
|
||||
|
||||
Network net;
|
||||
|
||||
// --8<-- [start:network_event_handler]
|
||||
// Network-level aggregate handler — covers every node, includes node name.
|
||||
net.set_event_handler([](std::string_view name, NodeEvent ev,
|
||||
steady_clock::time_point ts) {
|
||||
auto ms = duration_cast<milliseconds>(ts.time_since_epoch()).count();
|
||||
std::string_view kind = (ev == NodeEvent::Overflow) ? "overflow" : "closed";
|
||||
std::cerr << "[net:" << kind << "] node=" << name << " t=" << ms << "ms\n";
|
||||
});
|
||||
// --8<-- [end:network_event_handler]
|
||||
|
||||
net.add("source", src)
|
||||
.add("sink", snk)
|
||||
.connect("source", src.output<0>(), "sink", snk.input<0>())
|
||||
.build()
|
||||
.start();
|
||||
|
||||
std::this_thread::sleep_for(milliseconds(300));
|
||||
net.stop();
|
||||
|
||||
std::cout << "\nTotal overflows observed by per-node callback: "
|
||||
<< overflow_count.load() << '\n';
|
||||
}
|
||||
+33
-2
@@ -1,8 +1,35 @@
|
||||
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)
|
||||
@@ -11,9 +38,11 @@ kpn_example(03_multi_output)
|
||||
kpn_example(04_storage_policy)
|
||||
kpn_example(05_error_handling)
|
||||
kpn_example(06_watchdog)
|
||||
kpn_example(15_node_error_handler)
|
||||
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)
|
||||
|
||||
@@ -21,7 +50,9 @@ if(KPN_WEB_DEBUG)
|
||||
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 needed.
|
||||
# 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)
|
||||
|
||||
# 09 requires OpenCV — only build if found
|
||||
find_package(OpenCV QUIET COMPONENTS core imgproc highgui videoio)
|
||||
|
||||
+86
-8
@@ -7,12 +7,78 @@
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── Lossless single-output delivery ───────────────────────────────────────────
|
||||
//
|
||||
// Shared by RouterNode and FilterNode, which each deliver a value to exactly one
|
||||
// channel. Both previously did
|
||||
//
|
||||
// try { ch->push(val); } catch (const ChannelOverflowError&) {}
|
||||
//
|
||||
// which discards the value whenever the consumer is behind. 6595e6e made node
|
||||
// outputs lossless, 28e0667 stopped them parking a worker, and a8cfe73 did the
|
||||
// same for FanoutNode — these two were in none of them, and were the last
|
||||
// remaining users of the throwing push() on a data path.
|
||||
//
|
||||
// A dropped item does not degrade a downstream result, it silently changes one.
|
||||
// Worse, a dropped *sentinel* wedges the pipeline outright: EOF is what tells
|
||||
// every downstream node to shut down, and there is nothing after it to retry.
|
||||
// A filter that passes EOF by predicate but drops it by backpressure is a
|
||||
// pipeline that never terminates.
|
||||
//
|
||||
// So sentinels go out-of-band via push_sentinel (a dedicated slot that consumes
|
||||
// no ring capacity and cannot overflow), and everything else is retried until
|
||||
// taken. Like FanoutNode and unlike a pool node, these own a private thread, so
|
||||
// waiting here costs no scheduler worker and needs no space-callback park.
|
||||
// stop_flag_ is rechecked every pass so teardown cannot hang on a full output.
|
||||
//
|
||||
// `parked` receives the time spent waiting, which the caller charges to blocked
|
||||
// rather than exec — a parked node is idle, and charging it to exec reports the
|
||||
// node as busy exactly when it is the one being held up.
|
||||
//
|
||||
// Returns false if stopped with the value undelivered.
|
||||
template<typename T>
|
||||
bool deliver_one(Channel<T>* ch, T& val, const std::atomic<bool>& stop_flag,
|
||||
duration_t& parked) {
|
||||
if (is_sentinel_value(val)) {
|
||||
ch->push_sentinel(std::move(val));
|
||||
return true;
|
||||
}
|
||||
const auto park_from = clock_t::now();
|
||||
for (;;) {
|
||||
switch (ch->try_push(val)) {
|
||||
case Channel<T>::PushResult::Taken:
|
||||
parked = duration_t(clock_t::now() - park_from);
|
||||
return true;
|
||||
case Channel<T>::PushResult::Closed:
|
||||
// Nobody is listening any more; the channel has recorded the
|
||||
// drop. Retrying would spin until teardown noticed.
|
||||
parked = duration_t(clock_t::now() - park_from);
|
||||
return false;
|
||||
case Channel<T>::PushResult::Full:
|
||||
break; // fall through to the retry logic
|
||||
}
|
||||
if (stop_flag.load(std::memory_order_relaxed)) {
|
||||
// Teardown with work in hand and the output still full. One last
|
||||
// throwing push, purely so the channel's own stats record the
|
||||
// overflow — the point of the lossless path is that a loss is never
|
||||
// invisible, and a silent return here would reintroduce exactly the
|
||||
// hole this function exists to close.
|
||||
try { ch->push(std::move(val)); }
|
||||
catch (const ChannelOverflowError&) {}
|
||||
parked = duration_t(clock_t::now() - park_from);
|
||||
return false;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(50));
|
||||
}
|
||||
}
|
||||
|
||||
// ── RouterNode ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reads one item and pushes it to exactly one of N output channels, chosen by
|
||||
@@ -126,15 +192,20 @@ private:
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
// An out-of-range selector still drops by design (documented on
|
||||
// the class): the item was routed nowhere, not lost to a full
|
||||
// channel. Only the latter is what deliver_one exists to stop.
|
||||
std::size_t idx = selector_(val);
|
||||
if (idx < N && out_channels_[idx]) {
|
||||
try { out_channels_[idx]->push(val); }
|
||||
catch (const ChannelOverflowError&) {}
|
||||
}
|
||||
duration_t parked{0};
|
||||
bool delivered = true;
|
||||
if (idx < N && out_channels_[idx])
|
||||
delivered = deliver_one(out_channels_[idx], val, stop_flag_, parked);
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
stats_.record_exec(duration_t(t2 - t1) - parked,
|
||||
duration_t(t1 - t0) + parked, cpu0, cpu1);
|
||||
if (!delivered) break;
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
}
|
||||
@@ -261,12 +332,19 @@ private:
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
// A value the predicate rejects is dropped by design and is not
|
||||
// counted as a processed frame. One it accepts is now delivered
|
||||
// losslessly — including a sentinel, which a filter typically
|
||||
// passes unconditionally so downstream can shut down, and which
|
||||
// the old throwing push discarded whenever the output was full.
|
||||
if (pred_(val) && out_ch_) {
|
||||
try { out_ch_->push(val); }
|
||||
catch (const ChannelOverflowError&) {}
|
||||
duration_t parked{0};
|
||||
const bool delivered = deliver_one(out_ch_, val, stop_flag_, parked);
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
stats_.record_exec(duration_t(t2 - t1) - parked,
|
||||
duration_t(t1 - t0) + parked, cpu0, cpu1);
|
||||
if (!delivered) break;
|
||||
}
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
|
||||
+288
-12
@@ -58,6 +58,18 @@ public:
|
||||
ChannelClosedError() : std::runtime_error("channel closed") {}
|
||||
};
|
||||
|
||||
// Nothing available *right now* on a channel that is still open. Distinct from
|
||||
// ChannelClosedError, which means upstream is finished and never coming back.
|
||||
//
|
||||
// Conflating the two is expensive in one direction only: a consumer that reads
|
||||
// "empty" as "closed" stops a live node permanently, and because a stopping
|
||||
// node disables its own inputs and outputs, one benign empty read takes the
|
||||
// rest of the pipeline with it. The reverse costs nothing.
|
||||
class ChannelEmptyError : public std::runtime_error {
|
||||
public:
|
||||
ChannelEmptyError() : std::runtime_error("channel empty") {}
|
||||
};
|
||||
|
||||
// ── 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.
|
||||
@@ -124,7 +136,6 @@ public:
|
||||
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);
|
||||
@@ -132,10 +143,162 @@ public:
|
||||
wake_.fetch_add(1, std::memory_order_release);
|
||||
wake_.notify_one();
|
||||
|
||||
if (was_empty && push_callback_)
|
||||
// Level-triggered, not edge-triggered — see set_push_callback.
|
||||
if (push_callback_)
|
||||
push_callback_();
|
||||
}
|
||||
|
||||
/// Called when a pop frees a slot in a previously-full ring.
|
||||
///
|
||||
/// The mirror of `set_push_callback`, and it exists for the same reason:
|
||||
/// a producer must be able to *park* rather than spin. Without it the only
|
||||
/// lossless option is `push_blocking`, which sleeps inside the caller's
|
||||
/// thread — and when that thread is a scheduler worker, parking it starves
|
||||
/// every node pinned to it (see the hold-and-wait note on push_sentinel).
|
||||
void set_space_callback(std::function<void()> cb) { space_callback_ = std::move(cb); }
|
||||
|
||||
/// True when a push would currently succeed. Used to close the lost-wakeup
|
||||
/// race: a producer that parks must re-check after clearing its queued flag,
|
||||
/// because a space_callback fired in between would otherwise be swallowed.
|
||||
bool has_space() const {
|
||||
return tail_.load(std::memory_order_relaxed) -
|
||||
head_.load(std::memory_order_acquire) < capacity_;
|
||||
}
|
||||
|
||||
/// Outcome of a non-blocking push.
|
||||
///
|
||||
/// try_push used to return bool, and returned *true* for a closed channel —
|
||||
/// so "delivered" and "discarded because nobody is listening" were the same
|
||||
/// answer. Both mean "stop trying", which is why the callers were correct,
|
||||
/// but neither they nor the producer's own accounting could tell a value
|
||||
/// that arrived from one that was thrown away. Only the channel's drop
|
||||
/// counter knew.
|
||||
enum class PushResult { Taken, Full, Closed };
|
||||
|
||||
/// Non-blocking, lossless push. Returns Full when the ring is full, having
|
||||
/// changed nothing — the caller keeps the value and retries when woken.
|
||||
PushResult try_push(T& value) {
|
||||
if (!accepting_.load(std::memory_order_acquire)) {
|
||||
stats_.record_drop();
|
||||
return PushResult::Closed;
|
||||
}
|
||||
const std::size_t t = tail_.load(std::memory_order_relaxed);
|
||||
const std::size_t h = head_.load(std::memory_order_acquire);
|
||||
if (t - h >= capacity_) return PushResult::Full;
|
||||
|
||||
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
|
||||
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();
|
||||
// Level-triggered, not edge-triggered — see set_push_callback.
|
||||
if (push_callback_) push_callback_();
|
||||
return PushResult::Taken;
|
||||
}
|
||||
|
||||
// Lossless push with BACKPRESSURE: if the ring is full, wait for the consumer to
|
||||
// drain instead of dropping (the throwing push()) — the producer just runs slower.
|
||||
// Use when every value must be delivered (e.g. replaying a dump for scoring, where
|
||||
// a dropped frame silently corrupts the result). SPSC: only the sole producer may
|
||||
// call it. Returns false if the channel was disabled while waiting.
|
||||
bool push_blocking(T value) {
|
||||
for (;;) {
|
||||
if (!accepting_.load(std::memory_order_acquire)) {
|
||||
stats_.record_drop();
|
||||
return false;
|
||||
}
|
||||
const std::size_t t = tail_.load(std::memory_order_relaxed);
|
||||
const std::size_t h = head_.load(std::memory_order_acquire);
|
||||
if (t - h < capacity_) { // space available → normal push
|
||||
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
|
||||
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();
|
||||
// Level-triggered, not edge-triggered — see set_push_callback.
|
||||
if (push_callback_) push_callback_();
|
||||
return true;
|
||||
}
|
||||
// full: yield briefly and retry (consumer will drain)
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(50));
|
||||
}
|
||||
}
|
||||
|
||||
// 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()).
|
||||
//
|
||||
// The slot holds exactly one undelivered token. A second offered before the
|
||||
// first is taken is refused, not queued and not overwritten: two control
|
||||
// tokens on one channel means the stream ended twice, which is a caller
|
||||
// protocol error rather than backpressure, and silently coalescing them
|
||||
// would hide it.
|
||||
/// Outcome of offering a sentinel. SlotBusy is a protocol error, not
|
||||
/// backpressure: it means a second control token was offered while the
|
||||
/// first was still undelivered, and a channel carries at most one.
|
||||
enum class SentinelResult { Taken, Closed, SlotBusy };
|
||||
|
||||
/// Non-consuming form. `value` is left untouched unless the result is
|
||||
/// Taken, so a refused token is still the caller's to report.
|
||||
SentinelResult try_push_sentinel(T& value) {
|
||||
if (!accepting_.load(std::memory_order_acquire)) {
|
||||
stats_.record_drop();
|
||||
return SentinelResult::Closed;
|
||||
}
|
||||
// Refuse rather than overwrite. Overwriting lost the first token
|
||||
// silently, and worse, wrote eof_value_ while the consumer could be
|
||||
// moving the previous one out of it — a data race on the storage, which
|
||||
// for a shared_ptr payload is a torn refcount rather than a stale read.
|
||||
//
|
||||
// Checking here is what makes the slot a correct SPSC handshake: the
|
||||
// producer is the only writer of eof_value_ and the only one that sets
|
||||
// has_eof_, the consumer is the only one that clears it, so observing
|
||||
// false here means the consumer has finished with the storage and will
|
||||
// not touch it again until this store publishes the next token.
|
||||
//
|
||||
// Not counted as a drop, and this is the important part. A source that
|
||||
// has reached the end of its input keeps being polled and keeps
|
||||
// returning EOF — that is the normal steady state, not an error — so a
|
||||
// token arriving while one is already pending is a *re-offer*, and
|
||||
// refusing it loses nothing: the pending token carries the same
|
||||
// meaning and is already on its way. Counting it as a drop made a
|
||||
// clean run report data loss and exit non-zero.
|
||||
//
|
||||
// The cost of that choice, stated plainly: a genuinely distinct second
|
||||
// token would also be refused silently, and the channel cannot tell the
|
||||
// two apart. Re-offering is the case that actually occurs here, and the
|
||||
// delivery guarantee that matters — the first token arrives — holds
|
||||
// either way.
|
||||
if (has_eof_.load(std::memory_order_acquire))
|
||||
return SentinelResult::SlotBusy;
|
||||
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 SentinelResult::Taken;
|
||||
}
|
||||
|
||||
/// Consuming convenience form. Returns false when the token was not stored,
|
||||
/// whether because the channel is closed or because one is already pending.
|
||||
bool push_sentinel(T value) {
|
||||
return try_push_sentinel(value) == SentinelResult::Taken;
|
||||
}
|
||||
|
||||
// Blocking pop. Returns when an item is available.
|
||||
// Throws ChannelClosedError if the channel is disabled (regardless of fill).
|
||||
T pop() {
|
||||
@@ -148,21 +311,37 @@ public:
|
||||
// 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.
|
||||
//
|
||||
// Re-confirm emptiness against a fresh tail_ first: the snapshot
|
||||
// at the top of the loop may be stale (the producer can push more
|
||||
// values *and* the sentinel in the window since), and the sentinel
|
||||
// must never jump ahead of ring values pushed before it. The spin
|
||||
// and post-spin takes below already reload tail_ on the line above
|
||||
// them; this is the one take that used the loop-top snapshot.
|
||||
if (h == tail_.load(std::memory_order_acquire)) {
|
||||
T s; if (take_sentinel(s)) return s;
|
||||
}
|
||||
|
||||
if (!accepting_.load(std::memory_order_acquire))
|
||||
throw ChannelClosedError{};
|
||||
|
||||
for (std::size_t s = 0; s < spin_count_; ++s) {
|
||||
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() or disable() fires.
|
||||
// Re-check tail after loading w to guard against a lost wakeup.
|
||||
// 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;
|
||||
}
|
||||
@@ -173,6 +352,8 @@ public:
|
||||
throw ChannelClosedError{};
|
||||
T value = extract(std::move(buf_[h & ring_mask_]));
|
||||
head_.store(h + 1, std::memory_order_release);
|
||||
// A slot just freed: wake any producer parked on this channel.
|
||||
if (t - h >= capacity_ && space_callback_) space_callback_();
|
||||
stats_.record_pop();
|
||||
return value;
|
||||
}
|
||||
@@ -190,12 +371,19 @@ public:
|
||||
}
|
||||
|
||||
// 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 false;
|
||||
const std::size_t t = tail_.load(std::memory_order_acquire);
|
||||
if (h == t)
|
||||
return take_sentinel(out);
|
||||
out = extract(std::move(buf_[h & ring_mask_]));
|
||||
head_.store(h + 1, std::memory_order_release);
|
||||
stats_.record_pop();
|
||||
// Pool nodes pop only through here, so this is where a parked producer
|
||||
// gets woken: the ring was full, and it no longer is.
|
||||
if (t - h >= capacity_ && space_callback_) space_callback_();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -212,25 +400,75 @@ public:
|
||||
wake_.notify_all();
|
||||
}
|
||||
|
||||
// Register a callback fired when the queue transitions empty→non-empty.
|
||||
// Register a callback fired after every successful push.
|
||||
//
|
||||
// It fires on every push, not on the empty→non-empty transition, and that
|
||||
// is a correctness requirement rather than a simplification.
|
||||
//
|
||||
// The edge version tested `was_empty = (t == h)` using an `h` sampled
|
||||
// *before* the item was published. A PoolNode consumer decides whether to
|
||||
// run again from the level (count_ready → approx_size), so the two sides
|
||||
// could each read the other as stale and both stand down:
|
||||
//
|
||||
// producer (push) consumer (PoolNode firing)
|
||||
// ------------------------ ----------------------------
|
||||
// samples t=782, h=781
|
||||
// -> was_empty = false, no wake
|
||||
// pops idx 781, head_ = 782
|
||||
// count_ready(): head_==tail_==782
|
||||
// -> not ready, gate released to Idle
|
||||
// tail_.store(783)
|
||||
//
|
||||
// The item is in the ring, the node is idle, and no wake is outstanding.
|
||||
// Worse, the failure is absorbing: every later push now sees a non-empty
|
||||
// ring, so `was_empty` is false forever and the callback never fires again.
|
||||
// The node sleeps while its backlog grows and its consumer waits on it.
|
||||
//
|
||||
// Re-reading head_ after the tail_ store does not fix it. That is the
|
||||
// store-buffer pattern, and under acquire/release both sides may legally
|
||||
// read stale; forbidding it needs seq_cst on the producer's tail_ store and
|
||||
// head_ load *and* on the consumer's head_ store and tail_ load — a fence
|
||||
// on both hot paths. Firing unconditionally is correct by construction:
|
||||
// the callback runs after the publishing store, so a consumer that observes
|
||||
// the level at all observes the item.
|
||||
//
|
||||
// The redundant wakes are cheap. on_input_ready re-checks the level, and
|
||||
// SubmitGate::claim() collapses a wake arriving during a firing into the
|
||||
// firing already in flight, so the cost is one CAS, not one extra run.
|
||||
void set_push_callback(std::function<void()> cb) {
|
||||
push_callback_ = std::move(cb);
|
||||
}
|
||||
|
||||
// Size derived lazily from ring indices — no separate counter on the hot path.
|
||||
// Ring occupancy, derived lazily from indices — no separate counter on the
|
||||
// hot path. Excludes any out-of-band sentinel (that lives outside the ring).
|
||||
// head_ is loaded first, deliberately. Both indices only ever increase, so
|
||||
// reading head_ before tail_ can at worst under-report a concurrent push;
|
||||
// the other order can read a head_ that has advanced past the tail_ already
|
||||
// sampled, and the unsigned difference then wraps to ~2^64. A caller
|
||||
// polling "is this channel empty yet" against that value never terminates.
|
||||
std::size_t size() const {
|
||||
return tail_.load(std::memory_order_relaxed)
|
||||
- head_.load(std::memory_order_relaxed);
|
||||
const std::size_t h = head_.load(std::memory_order_relaxed);
|
||||
const std::size_t t = tail_.load(std::memory_order_acquire);
|
||||
return t - h;
|
||||
}
|
||||
|
||||
// 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::size_t approx_size() const { return size(); }
|
||||
|
||||
std::size_t capacity() const { return capacity_; }
|
||||
bool is_accepting() const { return accepting_.load(std::memory_order_relaxed); }
|
||||
const ChannelStats& stats() const { return stats_; }
|
||||
|
||||
ChannelSnapshot snapshot(const std::string& name) const {
|
||||
const std::size_t t = tail_.load(std::memory_order_relaxed);
|
||||
// head_ before tail_, for the reason given on size().
|
||||
const std::size_t h = head_.load(std::memory_order_relaxed);
|
||||
const std::size_t t = tail_.load(std::memory_order_acquire);
|
||||
return {
|
||||
name,
|
||||
capacity_,
|
||||
@@ -260,15 +498,53 @@ 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;
|
||||
// Re-check emptiness *after* observing has_eof_, not before.
|
||||
//
|
||||
// Callers check the ring is empty and then call this, but the producer
|
||||
// can push a value and publish the sentinel in the window between those
|
||||
// two steps — so the sentinel would be delivered with a real value still
|
||||
// queued behind it, breaking the "sentinel is strictly last" contract
|
||||
// that downstream teardown depends on. a0c4bf5 closed the variant where
|
||||
// the caller's emptiness check used a stale tail_ snapshot; this is the
|
||||
// one where the check is fresh but simply too early.
|
||||
//
|
||||
// Checking here is what makes it sound: the producer publishes the
|
||||
// sentinel with a release store *after* its ring pushes, so a consumer
|
||||
// that has observed has_eof_ has also observed every tail_ advance
|
||||
// before it. If the ring is non-empty now, those values genuinely
|
||||
// precede the sentinel and must be delivered first.
|
||||
if (head_.load(std::memory_order_relaxed)
|
||||
!= tail_.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_;
|
||||
std::function<void()> space_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};
|
||||
|
||||
@@ -51,6 +51,14 @@ struct NodeStats {
|
||||
std::atomic<int64_t> max_exec_us{0};
|
||||
std::atomic<int64_t> total_blocked_us{0};
|
||||
|
||||
// Cumulative wall time inside fire_once, summed over every invocation.
|
||||
// The EMA above cannot be turned into a total: it is exponentially
|
||||
// weighted, so frames * ema_exec_us tracks the tail of the run rather than
|
||||
// the whole of it, and on a workload whose per-frame cost varies (a face
|
||||
// detector on a film: crowd scenes then empty landscapes) the two differ by
|
||||
// a lot. Answering "how much time went into this node" needs a real sum.
|
||||
std::atomic<int64_t> total_exec_us{0};
|
||||
|
||||
// Thread CPU time — actual CPU consumed by this node's thread,
|
||||
// measured via CLOCK_THREAD_CPUTIME_ID. Excludes time sleeping or
|
||||
// blocked on mutexes/channels. Sampled once per frame.
|
||||
@@ -89,6 +97,7 @@ struct NodeStats {
|
||||
frames_processed.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
int64_t us = static_cast<int64_t>(exec_time.count() * 1000.0);
|
||||
total_exec_us.fetch_add(us, std::memory_order_relaxed);
|
||||
|
||||
uint64_t n = frames_processed.load(std::memory_order_relaxed);
|
||||
int64_t prev = ema_exec_us.load(std::memory_order_relaxed);
|
||||
@@ -147,6 +156,29 @@ struct NodeSnapshot {
|
||||
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
|
||||
|
||||
// Cumulative wall time inside fire_once. Unlike ema_exec_ms this is a true
|
||||
// sum, so it is the field to use for "share of the run spent in this node".
|
||||
// Note it still includes time parked pushing into a full output channel;
|
||||
// total_cpu_ms is the part that backpressure cannot inflate.
|
||||
//
|
||||
// Declared before the two bools below because every node type initialises
|
||||
// this aggregate positionally, and all of them supply total_exec_ms as the
|
||||
// element after queue_wait_ms.
|
||||
double total_exec_ms{0};
|
||||
|
||||
// Live scheduling state, for observing the AR-004 invariant "a node never
|
||||
// sleeps with a wake outstanding". The invariant was previously asserted in
|
||||
// comments but invisible at runtime, so a lost wake could only be found in a
|
||||
// debugger — and this bug does not reproduce under one (it needs full speed).
|
||||
// Two atomic loads at snapshot time, nothing on the hot path.
|
||||
//
|
||||
// Read them together with the node's channel fill:
|
||||
// queued=0, wake=1 -> wake recorded and never consumed
|
||||
// queued=0, wake=0, input full -> wake never generated at all
|
||||
// queued=1 while nothing running -> submitted but never scheduled
|
||||
bool queued{false};
|
||||
bool wake_pending{false};
|
||||
};
|
||||
|
||||
// ── Pool statistics + snapshot ────────────────────────────────────────────────
|
||||
@@ -188,6 +220,11 @@ struct ResourceSnapshot {
|
||||
struct IResourceProbe {
|
||||
virtual ~IResourceProbe() = default;
|
||||
virtual ResourceSnapshot snapshot(const std::string& name) const = 0;
|
||||
|
||||
/// Release every thread waiting for the resource, so teardown is not held
|
||||
/// up by one. A network calls this on the resources registered with it when
|
||||
/// it halts; default no-op for probes with nothing to wake.
|
||||
virtual void close() {}
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
+86
-8
@@ -7,8 +7,10 @@
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
@@ -78,7 +80,9 @@ public:
|
||||
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};
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||
0.0, // queue_wait_ms — fanout is not pool-scheduled
|
||||
stats_.total_exec_us.load(std::memory_order_relaxed) / 1000.0};
|
||||
}
|
||||
|
||||
// ── Port access ───────────────────────────────────────────────────────────
|
||||
@@ -116,6 +120,79 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
// Deliver `val` to every connected output, losslessly.
|
||||
//
|
||||
// Previously a full output cost the value: push() threw and the exception was
|
||||
// swallowed per output. A dropped item does not degrade a downstream result,
|
||||
// it silently changes one, and the consumer cannot tell it happened — so the
|
||||
// fanout waits instead, and the producer upstream runs slower.
|
||||
//
|
||||
// Unlike a pool node, a fanout owns a private thread, so waiting here costs
|
||||
// no scheduler worker and needs no space-callback park; a bounded retry is
|
||||
// enough. `stop_flag_` is re-checked every pass so teardown cannot hang on a
|
||||
// full output regardless of the order the network stops its nodes in.
|
||||
//
|
||||
// Outputs are retried independently, so a full output never delays delivery
|
||||
// to one with room. Note what that does *not* buy: the next input is not
|
||||
// popped until every output has accepted the current item, so one branch can
|
||||
// never run ahead of another by more than the slower branch's buffering.
|
||||
//
|
||||
// **That bound is a precondition on any topology where the branches rejoin.**
|
||||
// If a consumer on branch B blocks waiting for something branch A computes,
|
||||
// B's buffering must exceed the lead A needs, or the two wedge — B waiting on
|
||||
// A, A starved because the fanout is holding an item B will not take. Making
|
||||
// the fanout lossless is what puts that precondition on the topology; while
|
||||
// it dropped, the question could not arise.
|
||||
//
|
||||
// `parked` receives the time spent waiting on a full output, which the caller
|
||||
// charges to blocked rather than exec.
|
||||
//
|
||||
// Returns false if stopped with the value undelivered.
|
||||
bool deliver(const T& val, duration_t& parked) {
|
||||
std::array<std::optional<T>, N> pending;
|
||||
std::size_t outstanding = 0;
|
||||
for (std::size_t i = 0; i < N; ++i)
|
||||
if (out_channels_[i]) { pending[i].emplace(val); ++outstanding; }
|
||||
|
||||
bool first_pass = true;
|
||||
auto park_from = clock_t::now();
|
||||
|
||||
for (;;) {
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
if (!pending[i]) continue;
|
||||
// Taken or Closed both mean "stop trying" — delivered, or gone
|
||||
// with the drop recorded. Only Full is worth another pass.
|
||||
if (out_channels_[i]->try_push(*pending[i])
|
||||
!= Channel<T>::PushResult::Full) {
|
||||
pending[i].reset();
|
||||
--outstanding;
|
||||
}
|
||||
}
|
||||
if (first_pass) { park_from = clock_t::now(); first_pass = false; }
|
||||
|
||||
if (outstanding == 0) {
|
||||
parked = duration_t(clock_t::now() - park_from);
|
||||
return true;
|
||||
}
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||
// Teardown with work in hand. One last throwing push per
|
||||
// outstanding output, purely so the channel's own stats record
|
||||
// the loss (drop if it is disabled, overflow if it is merely
|
||||
// full). The whole point of the lossless path is that a loss is
|
||||
// never invisible, and a silent `return` here would reintroduce
|
||||
// exactly the hole this function exists to close.
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
if (!pending[i]) continue;
|
||||
try { out_channels_[i]->push(std::move(*pending[i])); }
|
||||
catch (const ChannelOverflowError&) {}
|
||||
}
|
||||
parked = duration_t(clock_t::now() - park_from);
|
||||
return false;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(50));
|
||||
}
|
||||
}
|
||||
|
||||
void run_loop() {
|
||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
@@ -124,16 +201,17 @@ private:
|
||||
auto t1 = clock_t::now();
|
||||
auto cpu0 = NodeStats::cpu_now();
|
||||
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
if (out_channels_[i]) {
|
||||
try { out_channels_[i]->push(val); }
|
||||
catch (const ChannelOverflowError&) {} // drop for this output independently
|
||||
}
|
||||
}
|
||||
duration_t parked{0};
|
||||
const bool delivered = deliver(val, parked);
|
||||
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
|
||||
// Time spent waiting on a full output is *blocked*, not exec: a
|
||||
// parked fanout is idle, and charging it to exec would report the
|
||||
// node as busy exactly when it is the one being held up.
|
||||
stats_.record_exec(duration_t(t2 - t1) - parked,
|
||||
duration_t(t1 - t0) + parked, cpu0, cpu1);
|
||||
if (!delivered) break;
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "diagnostics.hpp"
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
@@ -10,10 +11,34 @@ namespace kpn {
|
||||
// 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;
|
||||
|
||||
// Install channel callbacks, without starting anything.
|
||||
//
|
||||
// A node's push/space callbacks live in std::function members on channels
|
||||
// it shares with its neighbours, and a neighbour that is already running
|
||||
// reads them on its own thread. Writing one while the pipeline runs is a
|
||||
// data race on the std::function — ThreadSanitizer reports it, and the
|
||||
// consequence in the field was the missed startup wake a8cfe73 had to
|
||||
// patch around.
|
||||
//
|
||||
// So a network calls prepare() on every node before it calls start() on
|
||||
// any of them: all the writes happen while nothing is running, and once a
|
||||
// node is live the callbacks are read-only. start() calls prepare() itself
|
||||
// if it has not been called, so standalone nodes still work; it is
|
||||
// idempotent, and the network relies on that.
|
||||
virtual void prepare() {}
|
||||
|
||||
virtual void start() = 0;
|
||||
virtual void stop() = 0;
|
||||
virtual bool running() const = 0;
|
||||
@@ -21,6 +46,19 @@ struct INode {
|
||||
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) {}
|
||||
|
||||
// Network-level error listener. Consulted when a node's function throws
|
||||
// and no per-node handler resolved it. Without this the exception is
|
||||
// discarded and the failure is only visible as a Closed event, which says
|
||||
// a node stopped but not why — the difference between a diagnosis and a
|
||||
// guess. Same contract as NodeErrorHandler: true to continue, false to
|
||||
// stop the node.
|
||||
virtual void set_network_error_callback(NodeErrorHandler) {}
|
||||
|
||||
// halt(): alias for stop() — immediate, discards in-flight work.
|
||||
virtual void halt() { stop(); }
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ public:
|
||||
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 {
|
||||
@@ -99,6 +104,7 @@ public:
|
||||
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
total_ms > 0 ? 100.0 : 0.0,
|
||||
qwait_ms,
|
||||
stats_.total_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,8 +176,8 @@ private:
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||
} catch (const ChannelOverflowError& e) {
|
||||
std::cerr << "[kpn] interrupt node overflow: " << e.what() << "\n";
|
||||
} catch (const ChannelOverflowError&) {
|
||||
fire_callbacks(event_callbacks_);
|
||||
} catch (...) {
|
||||
if (!error_handler_ || !error_handler_(name_, std::current_exception()))
|
||||
fatal = true;
|
||||
@@ -180,6 +186,8 @@ private:
|
||||
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;
|
||||
@@ -230,15 +238,28 @@ private:
|
||||
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
|
||||
std::make_index_sequence<output_count>{}));
|
||||
|
||||
std::shared_ptr<IScheduler> scheduler_;
|
||||
std::string name_;
|
||||
std::size_t fifo_capacity_;
|
||||
output_channels_t output_channels_{};
|
||||
std::atomic<bool> stop_flag_{true};
|
||||
std::atomic<int> pending_{0}; // triggers awaiting execution
|
||||
NodeStats stats_;
|
||||
NodeErrorHandler error_handler_;
|
||||
std::chrono::milliseconds max_exec_time_{0};
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -90,6 +90,8 @@ public:
|
||||
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,
|
||||
0.0, // queue_wait_ms — main-thread node is not pool-scheduled
|
||||
stats_.total_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+103
-17
@@ -8,8 +8,10 @@
|
||||
#include <memory>
|
||||
#endif
|
||||
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <iomanip>
|
||||
#include <mutex>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <set>
|
||||
@@ -39,11 +41,21 @@ public:
|
||||
|
||||
class Network : public INode {
|
||||
public:
|
||||
using ErrorHandler =
|
||||
std::function<void(std::string_view node_name, std::exception_ptr)>;
|
||||
/// Application-level error listener. Receives the exception any node's
|
||||
/// function throws, after that node's own handler (if any) declined it.
|
||||
/// Return true to skip the failed invocation and keep the node running,
|
||||
/// false to let it stop.
|
||||
///
|
||||
/// Same type as StaticNetwork's, deliberately: this used to be a void
|
||||
/// signature, which could not express the keep-running decision and, more
|
||||
/// to the point, was never delivered anywhere.
|
||||
using ErrorHandler = NodeErrorHandler;
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -88,6 +100,7 @@ public:
|
||||
+ " → " + dst_name + ":" + std::to_string(DstIdx);
|
||||
channel_probes_.push_back(
|
||||
std::make_unique<ChannelProbe<out_t>>(in_ch, ch_name));
|
||||
channel_src_names_.push_back(src_name);
|
||||
|
||||
adj_[src_name].push_back(dst_name);
|
||||
return *this;
|
||||
@@ -111,6 +124,19 @@ public:
|
||||
for (auto& [name, _] : nodes_)
|
||||
if (color[name] == 0)
|
||||
dfs(name, color);
|
||||
if (event_handler_) {
|
||||
for (auto& name : topo_) {
|
||||
auto* node = nodes_.at(name);
|
||||
node->set_network_overflow_callback(
|
||||
[this, n = name](auto ts) {
|
||||
event_handler_(n, NodeEvent::Overflow, ts);
|
||||
});
|
||||
node->set_network_closed_callback(
|
||||
[this, n = name](auto ts) {
|
||||
event_handler_(n, NodeEvent::Closed, ts);
|
||||
});
|
||||
}
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -118,6 +144,17 @@ public:
|
||||
|
||||
void start() override {
|
||||
start_time_ = clock_t::now();
|
||||
// Deliver the listener to the nodes. Without this the handler was
|
||||
// stored and never read: a node's exception was discarded at the node
|
||||
// boundary and the only surviving evidence was a Closed event, which
|
||||
// says a node stopped but not why. StaticNetwork has always done this;
|
||||
// Network accepted the handler and silently dropped it.
|
||||
if (error_handler_)
|
||||
for (auto& name : topo_)
|
||||
nodes_.at(name)->set_network_error_callback(error_handler_);
|
||||
// Callbacks first, everywhere, before anything runs — see INode::prepare.
|
||||
for (auto& name : topo_)
|
||||
nodes_.at(name)->prepare();
|
||||
for (auto& name : topo_)
|
||||
nodes_.at(name)->start();
|
||||
start_watchdog();
|
||||
@@ -194,8 +231,14 @@ public:
|
||||
watchdog_interval_ = interval;
|
||||
}
|
||||
|
||||
void set_error_handler(ErrorHandler h) { error_handler_ = std::move(h); }
|
||||
/// How long shutdown() waits for one node's outputs to drain before giving
|
||||
/// up on them and stopping the next layer anyway.
|
||||
void set_drain_timeout(std::chrono::milliseconds t) { drain_timeout_ = t; }
|
||||
|
||||
/// Must be called before start(); the handler is delivered to nodes there.
|
||||
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);
|
||||
@@ -350,19 +393,46 @@ private:
|
||||
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));
|
||||
/// Wait for the channels fed by `name` to empty, or give up.
|
||||
///
|
||||
/// This took a node name and ignored it, polling *every* channel in the
|
||||
/// graph instead — so shutdown() waited for the whole network to be idle
|
||||
/// before stopping each successive layer. With no deadline either, anything
|
||||
/// wedged downstream turned a graceful shutdown into the hang it exists to
|
||||
/// avoid.
|
||||
///
|
||||
/// Two bounds, because they fail differently. The deadline covers a
|
||||
/// consumer that has stopped consuming, where fill never changes and
|
||||
/// waiting cannot help. The no-progress counter covers one that is merely
|
||||
/// slow: it keeps waiting while the queue is shrinking, so a slow drain is
|
||||
/// not cut short just for taking a while.
|
||||
void drain_output_channels(const std::string& name) const {
|
||||
const auto deadline = clock_t::now() + drain_timeout_;
|
||||
std::size_t last_fill = static_cast<std::size_t>(-1);
|
||||
int stalls = 0;
|
||||
|
||||
auto fill_of = [&] {
|
||||
std::size_t fill = 0;
|
||||
for (std::size_t i = 0; i < channel_probes_.size(); ++i)
|
||||
if (channel_src_names_[i] == name)
|
||||
fill += channel_probes_[i]->snapshot().current_fill;
|
||||
return fill;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const std::size_t fill = fill_of();
|
||||
if (fill == 0) return;
|
||||
if (fill >= last_fill) { if (++stalls > 100) break; }
|
||||
else { stalls = 0; }
|
||||
last_fill = fill;
|
||||
if (clock_t::now() >= deadline) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
if (const std::size_t left = fill_of())
|
||||
std::cerr << "[kpn] shutdown: '" << name << "' still has " << left
|
||||
<< " queued item(s) its consumer did not take; "
|
||||
"they are discarded\n";
|
||||
}
|
||||
|
||||
// ── Cycle detection / topological sort ───────────────────────────────────
|
||||
@@ -381,9 +451,20 @@ private:
|
||||
|
||||
void start_watchdog() {
|
||||
watchdog_ = std::jthread([this](std::stop_token tok) {
|
||||
// Interruptible wait, not sleep_for. request_stop() cannot wake a
|
||||
// sleeping thread, so stop_watchdog()'s join blocked for up to a
|
||||
// full interval — three seconds by default, and unbounded for
|
||||
// anyone who set a long one to keep the periodic report quiet.
|
||||
// Every teardown paid it.
|
||||
std::mutex m;
|
||||
std::condition_variable_any cv;
|
||||
while (!tok.stop_requested()) {
|
||||
std::this_thread::sleep_for(watchdog_interval_);
|
||||
if (tok.stop_requested()) break;
|
||||
{
|
||||
std::unique_lock lk(m);
|
||||
if (cv.wait_for(lk, tok, watchdog_interval_,
|
||||
[&tok] { return tok.stop_requested(); }))
|
||||
break;
|
||||
}
|
||||
|
||||
auto s = collect_snapshots();
|
||||
check_hung_nodes();
|
||||
@@ -427,9 +508,14 @@ 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_;
|
||||
/// Name of the node feeding each probe, parallel to channel_probes_.
|
||||
/// shutdown() drains a node's own outputs, so it has to know which they are.
|
||||
std::vector<std::string> channel_src_names_;
|
||||
std::chrono::milliseconds drain_timeout_{5000};
|
||||
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_;
|
||||
|
||||
+717
-96
File diff suppressed because it is too large
Load Diff
@@ -247,7 +247,7 @@ void bind_network(nb::module_& m) {
|
||||
|
||||
nb::class_<IVariantNode<Variant>>(m, "INode");
|
||||
|
||||
nb::class_<Net>(m, "Network")
|
||||
nb::class_<Net>(m, "Network", nb::type_slots(network_type_slots<Variant>()))
|
||||
.def("__init__", [](Net* self) {
|
||||
new (self) Net();
|
||||
register_all_converters<Registry>(*self);
|
||||
|
||||
@@ -35,6 +35,16 @@ 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) {
|
||||
@@ -207,6 +217,20 @@ public:
|
||||
return it->second;
|
||||
}
|
||||
|
||||
// Raw node handle by name — lets a binding dynamic_cast to a concrete wrapper
|
||||
// type and call its functor's runtime setters (persistent-pipeline reuse).
|
||||
VNode* node_ptr(const std::string& name) { return &node_at(name); }
|
||||
|
||||
// Per-node timing snapshot for profiling where a replay spends its time.
|
||||
std::map<std::string, double> node_stats(const std::string& name) {
|
||||
auto& n = node_at(name);
|
||||
NodeSnapshot s = n.node_snapshot(name, 0.0);
|
||||
return {{"frames", double(s.frames_processed)},
|
||||
{"exec_ms", s.ema_exec_ms}, {"max_ms", s.max_exec_ms},
|
||||
{"blocked_ms", s.total_blocked_ms}, {"fps", s.throughput_fps},
|
||||
{"cpu_ms", s.total_cpu_ms}, {"cpu_util_pct", s.cpu_util_pct}};
|
||||
}
|
||||
|
||||
private:
|
||||
VNode& node_at(const std::string& name) {
|
||||
auto it = nodes_.find(name);
|
||||
@@ -367,6 +391,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() {
|
||||
while (!stop_flag_.load(std::memory_order_relaxed)) {
|
||||
@@ -404,13 +436,17 @@ private:
|
||||
|
||||
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
|
||||
if (out_channels_[i])
|
||||
out_channels_[i]->push(std::move(outputs[i]));
|
||||
// Lossless: wait for space rather than drop. A dropped frame
|
||||
// silently corrupts a replay's score; backpressure just slows
|
||||
// the producer. (Was push() + "drop on overflow".)
|
||||
out_channels_[i]->push_blocking(std::move(outputs[i]));
|
||||
}
|
||||
|
||||
} catch (const ChannelClosedError&) {
|
||||
break;
|
||||
} catch (const ChannelOverflowError&) {
|
||||
// drop and continue
|
||||
// no longer reachable with push_blocking, kept for safety
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,6 +475,60 @@ 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.
|
||||
@@ -447,7 +537,7 @@ 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::class_<Net>(m, class_name, nb::type_slots(network_type_slots<Variant>()))
|
||||
.def(nb::init<>())
|
||||
.def("connect", &Net::connect,
|
||||
nb::arg("src"), nb::arg("out_idx"),
|
||||
@@ -458,7 +548,8 @@ void register_py_network(nb::module_& m, const char* class_name = "Network") {
|
||||
.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"));
|
||||
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"))
|
||||
.def("node_stats", &Net::node_stats, nb::arg("node"));
|
||||
}
|
||||
|
||||
} // namespace kpn::python
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#pragma once
|
||||
// ObjectVariantNodeWrapper — variant-node adapter for *stateful* functors.
|
||||
//
|
||||
// VariantNodeWrapper (variant_node.hpp) wraps Node<Func,...>, where Func is a
|
||||
// default-constructible NTTP callable. That doesn't fit nodes whose functor must
|
||||
// be constructed with runtime state (a Config, a loaded gallery, etc.) — those use
|
||||
// ObjectNode<Obj>, which takes `Obj& obj` at construction.
|
||||
//
|
||||
// This wrapper owns an Obj instance and exposes the same IVariantNode surface so a
|
||||
// stateful C++ node can live inside a PyNetwork. Build one via a factory that
|
||||
// constructs the functor from Python-supplied config, e.g.:
|
||||
//
|
||||
// auto n = std::make_shared<ObjectVariantNodeWrapper<
|
||||
// IdentityMatcherFunc, Variant, in<"tracked">, out<"matched">>>(
|
||||
// fifo_cap, gallery, cfg); // Obj ctor args forwarded
|
||||
// net.add("identity_matcher", n);
|
||||
//
|
||||
// The wrapper mirrors VariantNodeWrapper's channel plumbing exactly; only the
|
||||
// underlying node type (PoolObjectNode, holding Obj&) differs.
|
||||
|
||||
#include "../channel.hpp"
|
||||
#include "../node.hpp"
|
||||
#include "../variant_node.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <typeindex>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
template<typename Obj, typename Variant,
|
||||
typename InputTag = in<>,
|
||||
typename OutputTag = out<>>
|
||||
class ObjectVariantNodeWrapper;
|
||||
|
||||
template<typename Obj, typename Variant,
|
||||
fixed_string... InNames, fixed_string... OutNames>
|
||||
class ObjectVariantNodeWrapper<Obj, Variant, in<InNames...>, out<OutNames...>>
|
||||
: public IVariantNode<Variant>
|
||||
{
|
||||
using NodeT = ObjectNode<Obj, in<InNames...>, out<OutNames...>>;
|
||||
|
||||
public:
|
||||
using args_tuple = typename NodeT::args_tuple;
|
||||
using return_tuple = typename NodeT::return_tuple;
|
||||
|
||||
static constexpr std::size_t n_in = NodeT::input_count;
|
||||
static constexpr std::size_t n_out = NodeT::output_count;
|
||||
|
||||
// Owns the functor; forwards remaining args to Obj's constructor.
|
||||
template<typename... ObjArgs>
|
||||
explicit ObjectVariantNodeWrapper(std::size_t fifo_capacity, ObjArgs&&... obj_args)
|
||||
: obj_(std::forward<ObjArgs>(obj_args)...)
|
||||
, node_(obj_, fifo_capacity)
|
||||
, in_channels_(n_in)
|
||||
, out_channels_(n_out)
|
||||
, out_type_indices_(n_out, std::type_index(typeid(void)))
|
||||
{
|
||||
init_inputs(std::make_index_sequence<n_in>{}, fifo_capacity);
|
||||
init_out_types(std::make_index_sequence<n_out>{});
|
||||
}
|
||||
|
||||
// Access the owned functor so callers can invoke its runtime setters (e.g. to
|
||||
// change a threshold on a persistent pipeline without rebuilding the node).
|
||||
Obj& functor() { return obj_; }
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
void start() override { node_.start(); }
|
||||
void stop() override { node_.stop(); }
|
||||
bool running() const override { return node_.running(); }
|
||||
const NodeStats& stats() const override { return node_.stats(); }
|
||||
void set_name(std::string name) override { node_.set_name(std::move(name)); }
|
||||
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||
return node_.node_snapshot(name, elapsed_s);
|
||||
}
|
||||
|
||||
// ── IVariantNode ──────────────────────────────────────────────────────────
|
||||
std::size_t input_count() const override { return n_in; }
|
||||
std::size_t output_count() const override { return n_out; }
|
||||
|
||||
std::type_index input_type(std::size_t i) const override {
|
||||
return in_channels_[i]->type_index();
|
||||
}
|
||||
std::type_index output_type(std::size_t i) const override {
|
||||
return out_type_indices_[i];
|
||||
}
|
||||
|
||||
std::shared_ptr<IVariantChannel<Variant>> input_channel(std::size_t i) override {
|
||||
return in_channels_[i];
|
||||
}
|
||||
|
||||
void set_output_channel(std::size_t i,
|
||||
std::shared_ptr<IVariantChannel<Variant>> ch) override {
|
||||
set_output_impl(i, std::move(ch), std::make_index_sequence<n_out>{});
|
||||
}
|
||||
|
||||
private:
|
||||
template<std::size_t... Is>
|
||||
void init_inputs(std::index_sequence<Is...>, std::size_t cap) {
|
||||
((init_one_input<Is>(cap)), ...);
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void init_one_input(std::size_t cap) {
|
||||
using T = std::tuple_element_t<I, args_tuple>;
|
||||
auto shared_ch = std::make_shared<Channel<T>>(cap);
|
||||
node_.template set_input_channel<I>(shared_ch);
|
||||
in_channels_[I] = std::make_shared<VariantChannel<T, Variant>>(std::move(shared_ch));
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void init_out_types(std::index_sequence<Is...>) {
|
||||
((out_type_indices_[Is] =
|
||||
std::type_index(typeid(std::tuple_element_t<Is, return_tuple>))), ...);
|
||||
}
|
||||
|
||||
template<std::size_t... Is>
|
||||
void set_output_impl(std::size_t port,
|
||||
std::shared_ptr<IVariantChannel<Variant>> ch,
|
||||
std::index_sequence<Is...>) {
|
||||
bool matched = false;
|
||||
((Is == port && (set_output_at<Is>(std::move(ch)), matched = true)), ...);
|
||||
if (!matched)
|
||||
throw std::out_of_range("set_output_channel: port index out of range");
|
||||
}
|
||||
|
||||
template<std::size_t I>
|
||||
void set_output_at(std::shared_ptr<IVariantChannel<Variant>> ch) {
|
||||
using T = std::tuple_element_t<I, return_tuple>;
|
||||
auto* typed = dynamic_cast<VariantChannel<T, Variant>*>(ch.get());
|
||||
if (!typed)
|
||||
throw std::runtime_error(
|
||||
"set_output_channel: type mismatch at output port " + std::to_string(I));
|
||||
node_.template set_output_channel<I>(typed->raw_ptr());
|
||||
out_channels_[I] = std::move(ch);
|
||||
}
|
||||
|
||||
Obj obj_; // owned; node_ holds Obj& — declaration order keeps obj_ alive first
|
||||
NodeT node_;
|
||||
std::vector<std::shared_ptr<IVariantChannel<Variant>>> in_channels_;
|
||||
std::vector<std::shared_ptr<IVariantChannel<Variant>>> out_channels_;
|
||||
std::vector<std::type_index> out_type_indices_;
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
+174
-10
@@ -5,6 +5,7 @@
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <optional>
|
||||
#include <queue>
|
||||
#include <thread>
|
||||
@@ -52,6 +53,19 @@ public:
|
||||
}
|
||||
|
||||
void start() override {
|
||||
// Under the lifecycle lock for the same reason stop() is: submit()
|
||||
// reads queues_ and this rebuilds it. A network starts its nodes one at
|
||||
// a time, and a node already started fires into the next one's channel,
|
||||
// whose push callback submits — so a submission can genuinely land
|
||||
// while another pool is still inside start(). ThreadSanitizer reports
|
||||
// it as a read at submit() against this write, and the consequence is
|
||||
// worse than a torn read: push_back can reallocate the vector under a
|
||||
// reader that has already indexed it.
|
||||
//
|
||||
// Queues are all constructed before any worker is spawned, which is
|
||||
// what keeps worker_loop's own queues_[id] out of this — it never takes
|
||||
// the lock, so holding it across the spawn cannot deadlock.
|
||||
std::unique_lock lk(lifecycle_mx_);
|
||||
stopped_.store(false, std::memory_order_relaxed);
|
||||
queues_.clear();
|
||||
for (std::size_t i = 0; i < thread_count_; ++i)
|
||||
@@ -62,15 +76,30 @@ public:
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
stopped_.store(true, std::memory_order_seq_cst);
|
||||
// Close the pool to new work before touching anything, and do it under
|
||||
// the lifecycle lock so no submit() is midway through indexing queues_.
|
||||
{
|
||||
std::unique_lock lk(lifecycle_mx_);
|
||||
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);
|
||||
queued_.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();
|
||||
// Join without the lock: a worker's task may call submit(), which takes
|
||||
// it shared, and holding it here would deadlock against that.
|
||||
for (auto& t : workers_) if (t.joinable()) t.join();
|
||||
// Destroying the queues is what submit() must never race. By now
|
||||
// stopped_ is published, so any submit() that acquires the lock after
|
||||
// this point returns without touching them.
|
||||
std::unique_lock lk(lifecycle_mx_);
|
||||
workers_.clear();
|
||||
queues_.clear();
|
||||
}
|
||||
@@ -83,28 +112,89 @@ public:
|
||||
}
|
||||
|
||||
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_;
|
||||
// A submission can arrive after this pool has been stopped, and did so
|
||||
// by an ordinary route: a node's space callback fires from whichever
|
||||
// thread drained the channel, which belongs to the *consumer*. Stop the
|
||||
// producer first — as a sources-first shutdown does — and the consumer
|
||||
// keeps draining its backlog, firing the producer's space callback into
|
||||
// a pool whose stop() has already run queues_.clear(). submit() then
|
||||
// indexed an empty vector: a segfault, reproducible about 12 runs in 20.
|
||||
//
|
||||
// The shared lock is what makes the check meaningful. Reading stopped_
|
||||
// alone leaves the window between the read and the indexing, which is
|
||||
// precisely where stop() clears the vector.
|
||||
std::shared_lock lk(lifecycle_mx_);
|
||||
if (stopped_.load(std::memory_order_acquire) || queues_.empty()) {
|
||||
rejected_.fetch_add(1, std::memory_order_relaxed);
|
||||
return;
|
||||
}
|
||||
// B9 — submit-to-self affinity. Round-robin hands every task to a
|
||||
// *different* worker, and on a pool of two or more that worker is
|
||||
// asleep, so each dispatch pays a futex wake: measured 182 ns/dispatch
|
||||
// on a 1-thread pool against 3197 ns on 20 threads, with voluntary
|
||||
// context switches per task rising 0.00 -> 1.19 in step.
|
||||
//
|
||||
// A submission originating on one of *our own* workers goes to that
|
||||
// worker's queue instead. It is about to return to worker_loop and
|
||||
// try_pop its own queue, so the work is already there and nothing
|
||||
// sleeps — the property that makes ThreadPool(1) fast, extended to
|
||||
// any pool size. Imbalance is corrected by the existing try_steal.
|
||||
//
|
||||
// The pool identity check is load-bearing: a worker of pool A
|
||||
// submitting into pool B must not use A's index, which may exceed B's
|
||||
// thread_count_ or alias an unrelated queue. Nested networks do
|
||||
// exactly this.
|
||||
std::size_t target;
|
||||
if (tls_pool == this && tls_worker < thread_count_) {
|
||||
target = tls_worker;
|
||||
} else {
|
||||
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);
|
||||
queued_.fetch_add(1, std::memory_order_relaxed);
|
||||
submitted_.fetch_add(1, std::memory_order_relaxed);
|
||||
cv_.notify_one();
|
||||
// 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.
|
||||
//
|
||||
// Skipped entirely when no worker is parked. waiters_ is incremented
|
||||
// *before* wait() releases cv_mx_ and decremented after it returns,
|
||||
// both under that mutex, so a worker on its way to sleep is already
|
||||
// counted here. Reading zero therefore means no worker can be in
|
||||
// wait(), and there is nothing a notify could reach — as opposed to
|
||||
// reading zero because we raced one, which the mutex prevents.
|
||||
//
|
||||
// This is the hot path for an already-busy pool: with B9 the work is
|
||||
// in the local queue and the submitting worker will find it itself,
|
||||
// so the lock round-trip and notify were pure overhead. Measured 1.00
|
||||
// voluntary context switches per task before this, on a pool where
|
||||
// only one task is ever in flight.
|
||||
if (waiters_.load(std::memory_order_seq_cst) != 0) {
|
||||
{ std::lock_guard<std::mutex> lk(cv_mx_); }
|
||||
cv_.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t thread_count() const { return thread_count_; }
|
||||
|
||||
/// Submissions dropped because the pool was stopped. See rejected_.
|
||||
uint64_t rejected() const { return rejected_.load(std::memory_order_relaxed); }
|
||||
|
||||
// ── 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
|
||||
queued_.load(std::memory_order_relaxed), // queued (exact)
|
||||
a, // executing
|
||||
submitted_.load(std::memory_order_relaxed),
|
||||
completed_.load(std::memory_order_relaxed),
|
||||
};
|
||||
@@ -133,11 +223,23 @@ private:
|
||||
if (q.pq.empty()) return std::nullopt;
|
||||
auto fn = std::move(const_cast<Task&>(q.pq.top()).fn);
|
||||
q.pq.pop();
|
||||
queued_.fetch_sub(1, std::memory_order_relaxed);
|
||||
return fn;
|
||||
}
|
||||
|
||||
std::optional<std::function<void()>> try_steal(std::size_t thief) {
|
||||
// Find the most-loaded peer without blocking — racy peek is fine.
|
||||
//
|
||||
// The threshold is >0: a peer holding a single task is a valid victim.
|
||||
//
|
||||
// Raising it to >1 — to stop a thief winning the race for a task its
|
||||
// owner just submitted to itself (B9) — deadlocks. `latency` mode
|
||||
// hangs at 12 and 20 threads: an external submit() round-robins one
|
||||
// task onto an idle worker's queue, and if that worker is parked, no
|
||||
// peer will take it because a queue of one is no longer stealable.
|
||||
// Nothing else is coming to wake it, so the pool sits forever.
|
||||
// Measured before reverting: it also made steady-state *worse*,
|
||||
// 2229 -> 4546 ns at 12 threads.
|
||||
std::size_t victim = thief, best = 0;
|
||||
for (std::size_t i = 0; i < queues_.size(); ++i) {
|
||||
if (i == thief) continue;
|
||||
@@ -157,41 +259,103 @@ private:
|
||||
active_.fetch_sub(1, std::memory_order_relaxed);
|
||||
// Notify drain() if this was the last in-flight task.
|
||||
// acq_rel ensures the decrement is visible before any drain() load.
|
||||
if (total_.fetch_sub(1, std::memory_order_acq_rel) == 1)
|
||||
// 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) {
|
||||
// Identify this thread as one of our workers, for B9's affinity check
|
||||
// in submit(). Restored on exit rather than merely cleared: a pool
|
||||
// whose worker runs a task that itself starts and stops a nested pool
|
||||
// would otherwise come back with its identity erased.
|
||||
ThreadPool* const prev_pool = tls_pool;
|
||||
const std::size_t prev_worker = tls_worker;
|
||||
tls_pool = this;
|
||||
tls_worker = id;
|
||||
struct Restore {
|
||||
ThreadPool* p; std::size_t w;
|
||||
~Restore() { tls_pool = p; tls_worker = w; }
|
||||
} restore{prev_pool, prev_worker};
|
||||
|
||||
while (true) {
|
||||
if (auto fn = try_pop(*queues_[id])) { execute(*fn); continue; }
|
||||
if (auto fn = try_steal(id)) { execute(*fn); continue; }
|
||||
|
||||
// B5 (bounded spin before parking) was tried here and removed: it
|
||||
// does not pay. Swept at 50/200/1000 rounds on a 12-thread pool,
|
||||
// steady state went 2123 / 2230 / 2574 ns against 2229 ns without
|
||||
// it, and voluntary context switches per task stayed at ~0.97
|
||||
// throughout. The spin cannot catch what it is aimed at, because
|
||||
// a peer is woken the moment queued_ becomes non-zero — which
|
||||
// happens before this worker reaches the spin at all.
|
||||
std::unique_lock lock(cv_mx_);
|
||||
// Counted under cv_mx_ and before the predicate is evaluated, so
|
||||
// that a submit() which reads waiters_ == 0 can be certain this
|
||||
// worker is not about to block: to get here we already hold the
|
||||
// mutex that submit() must take to notify.
|
||||
waiters_.fetch_add(1, std::memory_order_seq_cst);
|
||||
cv_.wait(lock, [this] {
|
||||
return stopped_.load(std::memory_order_seq_cst)
|
||||
|| total_.load(std::memory_order_relaxed) > 0;
|
||||
|| queued_.load(std::memory_order_relaxed) > 0;
|
||||
});
|
||||
waiters_.fetch_sub(1, std::memory_order_seq_cst);
|
||||
// Exit on queued_, not total_: waiting for total_ to reach zero
|
||||
// meant waiting for someone else's task to finish, which this
|
||||
// worker cannot help with and would spin through until it did.
|
||||
if (stopped_.load(std::memory_order_seq_cst)
|
||||
&& total_.load(std::memory_order_relaxed) == 0)
|
||||
&& queued_.load(std::memory_order_relaxed) == 0)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Which pool, and which of its workers, the calling thread is — or
|
||||
/// nullptr on any thread that is not a pool worker. Read by submit() to
|
||||
/// decide whether a local push is safe (B9). inline so the header stays
|
||||
/// header-only.
|
||||
static inline thread_local ThreadPool* tls_pool = nullptr;
|
||||
static inline thread_local std::size_t tls_worker = 0;
|
||||
|
||||
const std::size_t thread_count_;
|
||||
std::vector<std::unique_ptr<WorkerQueue>> queues_;
|
||||
std::vector<std::thread> workers_;
|
||||
|
||||
/// Guards the lifetime of queues_/workers_ against a concurrent submit().
|
||||
/// Shared by submit, exclusive by stop, so submissions still run in
|
||||
/// parallel with each other.
|
||||
mutable std::shared_mutex lifecycle_mx_;
|
||||
|
||||
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> total_{0}; // queued + executing (drain() waits on this)
|
||||
/// Queued only — never counts a task that is already executing.
|
||||
///
|
||||
/// The wait predicate used total_, which includes running tasks, so while
|
||||
/// any one task ran every *other* worker's predicate was true: wait()
|
||||
/// returned instantly and the worker spun through try_pop / try_steal /
|
||||
/// wait at full speed, try_lock-ing every peer queue on each pass. One slow
|
||||
/// task therefore pinned every other core and contended the very mutexes
|
||||
/// the working thread needed. Sleeping requires "no work is *waiting*",
|
||||
/// which is this.
|
||||
std::atomic<size_t> queued_{0}; // waiting to run
|
||||
std::atomic<size_t> active_{0}; // executing only (for snapshot)
|
||||
std::atomic<size_t> next_{0}; // round-robin submit cursor
|
||||
/// Workers currently inside cv_.wait(), maintained under cv_mx_. Lets
|
||||
/// submit() skip the lock round-trip and notify when nobody is parked.
|
||||
std::atomic<size_t> waiters_{0};
|
||||
std::atomic<uint64_t> seq_{0}; // tie-break for equal-priority tasks
|
||||
std::atomic<uint64_t> submitted_{0};
|
||||
/// Submissions refused because the pool was already stopped. Not an error —
|
||||
/// teardown races are expected — but silence here would hide a node that
|
||||
/// keeps trying to run after its pool is gone.
|
||||
std::atomic<uint64_t> rejected_{0};
|
||||
std::atomic<uint64_t> completed_{0};
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,18 @@ namespace kpn {
|
||||
|
||||
template<typename T> class Channel; // forward declaration for acquire_balanced
|
||||
|
||||
/// Thrown by a pending acquire() when the resource is closed underneath it.
|
||||
///
|
||||
/// acquire() blocks on a condition variable with no timeout and no stop
|
||||
/// condition, so a node parked there ignored teardown entirely: the worker
|
||||
/// never returned, the pool's join never completed, and shutdown hung on a
|
||||
/// resource nobody was going to release. Closing the resource turns that into
|
||||
/// an exception the node's normal error path already handles.
|
||||
class ResourceClosedError : public std::runtime_error {
|
||||
public:
|
||||
ResourceClosedError() : std::runtime_error("shared resource closed") {}
|
||||
};
|
||||
|
||||
// ── SharedResource ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Wraps an exclusive resource (e.g. an ONNX session, a CUDA stream) and
|
||||
@@ -72,6 +84,7 @@ public:
|
||||
template<typename PriorityFn>
|
||||
Guard acquire(PriorityFn&& fn) {
|
||||
std::unique_lock lock(mutex_);
|
||||
if (closed_) throw ResourceClosedError{};
|
||||
if (!held_) {
|
||||
held_ = true;
|
||||
acq_.fetch_add(1, std::memory_order_relaxed);
|
||||
@@ -83,18 +96,46 @@ public:
|
||||
current_waiters_.store(waiters_.size(), std::memory_order_relaxed);
|
||||
|
||||
auto t0 = w.wait_start;
|
||||
w.cv.wait(lock, [&w] { return w.ready; });
|
||||
// Woken either by release() handing over ownership, or by close()
|
||||
// giving up on the wait entirely.
|
||||
w.cv.wait(lock, [&w] { return w.ready || w.closed; });
|
||||
|
||||
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);
|
||||
|
||||
// Closed without being handed ownership: no Guard, so nothing to
|
||||
// release, and held_ is left exactly as close() found it.
|
||||
if (!w.ready) throw ResourceClosedError{};
|
||||
|
||||
acq_.fetch_add(1, std::memory_order_relaxed);
|
||||
return Guard(this);
|
||||
}
|
||||
|
||||
/// Wake every waiter and refuse further acquisitions.
|
||||
///
|
||||
/// Teardown is the whole point: a node parked in acquire() is not
|
||||
/// observing stop flags, so without this the only way out is for whoever
|
||||
/// holds the resource to release it — which, if that node is also being
|
||||
/// stopped, may never happen. Idempotent, and safe to call from any thread.
|
||||
void close() override {
|
||||
std::lock_guard lock(mutex_);
|
||||
closed_ = true;
|
||||
for (Waiter* w : waiters_) {
|
||||
w->closed = true;
|
||||
w->cv.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reopen after a close(). For reuse across runs; not needed for teardown.
|
||||
void reopen() {
|
||||
std::lock_guard lock(mutex_);
|
||||
closed_ = false;
|
||||
}
|
||||
|
||||
// Acquire with no priority (all waiters treated equally, order is fair-ish).
|
||||
Guard acquire() {
|
||||
return acquire([] { return 0.5f; });
|
||||
@@ -132,7 +173,10 @@ public:
|
||||
private:
|
||||
void release() {
|
||||
std::unique_lock lock(mutex_);
|
||||
if (waiters_.empty()) {
|
||||
// Hand over only to a waiter that is still waiting. A closed one is on
|
||||
// its way out and will not take ownership, so treating it as the next
|
||||
// holder would leave held_ true with nobody holding it.
|
||||
if (closed_ || waiters_.empty()) {
|
||||
held_ = false;
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +206,8 @@ private:
|
||||
std::function<float()> priority_fn;
|
||||
clock_t::time_point wait_start;
|
||||
std::condition_variable cv;
|
||||
bool ready{false};
|
||||
bool ready{false}; // handed ownership by release()
|
||||
bool closed{false}; // woken by close() instead
|
||||
|
||||
Waiter(std::function<float()> fn, clock_t::time_point t)
|
||||
: priority_fn(std::move(fn)), wait_start(t) {}
|
||||
@@ -172,6 +217,7 @@ private:
|
||||
|
||||
T resource_;
|
||||
bool held_{false};
|
||||
bool closed_{false};
|
||||
mutable std::mutex mutex_;
|
||||
std::vector<Waiter*> waiters_;
|
||||
std::atomic<uint64_t> acq_{0};
|
||||
|
||||
+138
-37
@@ -98,13 +98,15 @@ public:
|
||||
std::vector<INode*> fanout_ptrs,
|
||||
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::unique_ptr<IChannelProbe>> channel_probes,
|
||||
std::vector<std::string> channel_src_names)
|
||||
: fanouts_(std::move(fanouts))
|
||||
, user_nodes_topo_(std::move(user_nodes_topo))
|
||||
, fanout_nodes_ptr_(std::move(fanout_ptrs))
|
||||
, user_node_names_(std::move(user_node_names))
|
||||
, fanout_node_names_(std::move(fanout_node_names))
|
||||
, channel_probes_(std::move(channel_probes))
|
||||
, channel_src_names_(std::move(channel_src_names))
|
||||
{}
|
||||
|
||||
~StaticNetwork() override { stop(); }
|
||||
@@ -112,6 +114,29 @@ 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); });
|
||||
}
|
||||
}
|
||||
if (error_handler_) {
|
||||
for (auto* node : user_nodes_topo_)
|
||||
node->set_network_error_callback(error_handler_);
|
||||
}
|
||||
// Install every node's channel callbacks before starting any of them.
|
||||
// Those callbacks are std::function members on channels shared with
|
||||
// neighbours; a neighbour that is already running reads them from its
|
||||
// own thread, so writing one after the pipeline is live is a data race
|
||||
// (ThreadSanitizer reports it on any multi-node network). Doing all the
|
||||
// writes here, while nothing runs, makes them read-only thereafter.
|
||||
for (auto* n : user_nodes_topo_) n->prepare();
|
||||
for (auto* n : fanout_nodes_ptr_) n->prepare();
|
||||
|
||||
for (auto* n : user_nodes_topo_) n->start();
|
||||
for (auto* n : fanout_nodes_ptr_) n->start();
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
@@ -135,6 +160,11 @@ public:
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_) web_server_->stop();
|
||||
#endif
|
||||
// Release anything parked on a shared resource first. A node blocked in
|
||||
// acquire() is not watching stop flags, so stopping it would wait on a
|
||||
// handover that may never come — its holder is being stopped too.
|
||||
for (auto& [rname, probe] : resource_probes_) { (void)rname; probe->close(); }
|
||||
|
||||
for (auto it = fanout_nodes_ptr_.rbegin(); it != fanout_nodes_ptr_.rend(); ++it)
|
||||
(*it)->stop();
|
||||
for (auto it = user_nodes_topo_.rbegin(); it != user_nodes_topo_.rend(); ++it)
|
||||
@@ -148,11 +178,12 @@ public:
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_) web_server_->stop();
|
||||
#endif
|
||||
for (auto& [rname, probe] : resource_probes_) { (void)rname; probe->close(); }
|
||||
// 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 (std::size_t i = 0; i < user_nodes_topo_.size(); ++i) {
|
||||
user_nodes_topo_[i]->stop();
|
||||
drain_outputs_of(user_node_names_[i]);
|
||||
}
|
||||
for (auto* n : fanout_nodes_ptr_) n->stop();
|
||||
}
|
||||
@@ -165,6 +196,24 @@ 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); }
|
||||
|
||||
/// How long shutdown() waits for one node's outputs to drain before giving
|
||||
/// up on them and stopping the next layer anyway.
|
||||
void set_drain_timeout(std::chrono::milliseconds t) { drain_timeout_ = t; }
|
||||
|
||||
/// Application-level error listener. Receives the exception any node's
|
||||
/// function throws, after that node's own handler (if any) declined it.
|
||||
/// Return true to skip the failed invocation and keep the node running,
|
||||
/// false to let it stop. Without a listener the exception is discarded
|
||||
/// and only a Closed event survives, which reports that a node stopped
|
||||
/// but not why.
|
||||
void set_error_handler(NodeErrorHandler h) { error_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.
|
||||
@@ -237,16 +286,50 @@ private:
|
||||
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));
|
||||
/// Wait for the channels fed by `src` to empty, or give up.
|
||||
///
|
||||
/// This was an unbounded `while (anything anywhere is non-empty)` poll over
|
||||
/// *every* channel in the graph, which made shutdown() wait for the whole
|
||||
/// network to be idle before stopping each successive layer, and wait
|
||||
/// forever if anything downstream was wedged — turning a graceful shutdown
|
||||
/// into the hang it exists to avoid.
|
||||
///
|
||||
/// Two bounds, because they fail differently. The deadline covers a
|
||||
/// consumer that has stopped consuming: fill never changes and no amount of
|
||||
/// waiting helps. The no-progress counter covers a consumer that is merely
|
||||
/// slow — it keeps waiting as long as the queue is shrinking, so a slow
|
||||
/// drain is not cut short just for exceeding a fixed time.
|
||||
///
|
||||
/// Giving up is reported rather than silent: undrained data at this point
|
||||
/// means values are about to be discarded by the stop that follows.
|
||||
void drain_outputs_of(const std::string& src) const {
|
||||
const auto deadline = clock_t::now() + drain_timeout_;
|
||||
std::size_t last_fill = static_cast<std::size_t>(-1);
|
||||
int stalls = 0;
|
||||
|
||||
for (;;) {
|
||||
std::size_t fill = 0;
|
||||
for (std::size_t i = 0; i < channel_probes_.size(); ++i)
|
||||
if (channel_src_names_[i] == src)
|
||||
fill += channel_probes_[i]->snapshot().current_fill;
|
||||
|
||||
if (fill == 0) return;
|
||||
if (fill >= last_fill) { if (++stalls > 100) break; }
|
||||
else { stalls = 0; }
|
||||
last_fill = fill;
|
||||
|
||||
if (clock_t::now() >= deadline) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
std::size_t left = 0;
|
||||
for (std::size_t i = 0; i < channel_probes_.size(); ++i)
|
||||
if (channel_src_names_[i] == src)
|
||||
left += channel_probes_[i]->snapshot().current_fill;
|
||||
if (left)
|
||||
std::cerr << "[kpn] shutdown: '" << src << "' still has " << left
|
||||
<< " queued item(s) its consumer did not take; "
|
||||
"they are discarded\n";
|
||||
}
|
||||
|
||||
std::string name_;
|
||||
@@ -257,8 +340,14 @@ private:
|
||||
std::vector<std::string> user_node_names_;
|
||||
std::vector<std::string> fanout_node_names_;
|
||||
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
|
||||
/// Display name of the node feeding each probe, parallel to channel_probes_.
|
||||
/// shutdown() drains a node's own outputs, so it has to know which they are.
|
||||
std::vector<std::string> channel_src_names_;
|
||||
std::chrono::milliseconds drain_timeout_{5000};
|
||||
std::vector<std::pair<std::string, IResourceProbe*>> resource_probes_;
|
||||
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
|
||||
EventHandler event_handler_;
|
||||
NodeErrorHandler error_handler_;
|
||||
clock_t::time_point start_time_;
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
uint16_t web_debug_port_{9090};
|
||||
@@ -291,29 +380,6 @@ auto make_network(Edges&&... edges) {
|
||||
// 4. Construct owned fanout storage on the heap (FanoutNode has jthread — not moveable)
|
||||
auto fanout_storage = std::make_unique<FanoutSto>();
|
||||
|
||||
// 5. Collect unique user node pointers + their display names, in edge-declaration order
|
||||
std::vector<INode*> user_node_ptrs;
|
||||
std::vector<std::string> user_node_names;
|
||||
auto collect = [&](auto& e) {
|
||||
using SrcT = std::decay_t<decltype(e.src)>;
|
||||
using DstT = std::decay_t<decltype(e.dst)>;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
(collect(edges), ...);
|
||||
|
||||
// 5. Wire all expanded SimpleEdges.
|
||||
// find_node<NodeT>: searches fanout storage then user edge pack, returns NodeT*.
|
||||
// Uses if constexpr in a fold so mismatched types never reach assignment.
|
||||
@@ -338,6 +404,38 @@ auto make_network(Edges&&... edges) {
|
||||
return ptr;
|
||||
};
|
||||
|
||||
// 5. Collect user node pointers + display names in *topological* order.
|
||||
//
|
||||
// Topo is computed above for the cycle check and used to be discarded,
|
||||
// while this vector was filled in edge-declaration order — and then named
|
||||
// user_nodes_topo_ and relied upon as if it were sorted. halt() stops in
|
||||
// its reverse, and shutdown() walks it forwards stopping each node and
|
||||
// draining its outputs before the next, which is only a graceful drain if
|
||||
// the order really is sources-first. It held for every network in the tree
|
||||
// because edges happen to be declared in pipeline order, and would have
|
||||
// broken silently for one that was not.
|
||||
//
|
||||
// Fanout nodes appear in Topo too; they are skipped here because they are
|
||||
// owned separately, in fanout_storage.
|
||||
std::vector<INode*> user_node_ptrs;
|
||||
std::vector<std::string> user_node_names;
|
||||
[&]<typename... Ns>(tmp::TypeList<Ns...>) {
|
||||
([&]<typename NodeT>() {
|
||||
if constexpr (!requires { NodeT::is_fanout_node; }) {
|
||||
if (auto* p = find_node.template operator()<NodeT>()) {
|
||||
auto* n = static_cast<INode*>(p);
|
||||
if (std::find(user_node_ptrs.begin(), user_node_ptrs.end(), n)
|
||||
== user_node_ptrs.end()) {
|
||||
auto nm = node_display_name<NodeT>();
|
||||
user_node_ptrs.push_back(n);
|
||||
user_node_names.push_back(nm);
|
||||
n->set_name(nm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}.template operator()<Ns>(), ...);
|
||||
}(typename Topo::topo{});
|
||||
|
||||
// Pre-pass: build fanout_id → source display name map so fanout nodes
|
||||
// can be named after the node feeding them (e.g. "capture_fanout").
|
||||
std::map<std::size_t, std::string> fanout_src_name;
|
||||
@@ -362,6 +460,7 @@ auto make_network(Edges&&... edges) {
|
||||
};
|
||||
|
||||
std::vector<std::unique_ptr<IChannelProbe>> channel_probes;
|
||||
std::vector<std::string> channel_src_names;
|
||||
|
||||
auto wire_one = [&]<typename SE>(SE) {
|
||||
using SrcNode = typename SE::src_node_t;
|
||||
@@ -379,6 +478,7 @@ auto make_network(Edges&&... edges) {
|
||||
+ " \xe2\x86\x92 " // UTF-8 →
|
||||
+ node_name.template operator()<DstNode>() + ":" + std::to_string(DstIdx);
|
||||
channel_probes.push_back(std::make_unique<ChannelProbe<out_t>>(ch, ch_name));
|
||||
channel_src_names.push_back(node_name.template operator()<SrcNode>());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -406,7 +506,8 @@ auto make_network(Edges&&... edges) {
|
||||
std::move(fanout_ptrs),
|
||||
std::move(user_node_names),
|
||||
std::move(fanout_node_names),
|
||||
std::move(channel_probes));
|
||||
std::move(channel_probes),
|
||||
std::move(channel_src_names));
|
||||
}
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── SubmitGate ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Decides, for one node, whether a wake must turn into a scheduler submission.
|
||||
// Exactly one firing of a node may be in flight at a time, and a wake that
|
||||
// arrives while one is already in flight must not be lost — it has to be
|
||||
// honoured when that firing finishes, or the node sleeps holding work.
|
||||
//
|
||||
// 9c5ce5f wrote this as two independent atomics: queued_ said a firing was in
|
||||
// flight, wake_pending_ recorded a wake that arrived during one. That cannot be
|
||||
// made correct, because the release side has to read and write both, and a wake
|
||||
// can land between the two operations:
|
||||
//
|
||||
// producer (try_submit) worker (release_and_recheck)
|
||||
// ------------------------ ----------------------------
|
||||
// CAS reads queued_ == true, fails
|
||||
// queued_.store(false)
|
||||
// wake_pending_.exchange(false) -> false
|
||||
// wake_pending_.store(true)
|
||||
//
|
||||
// End state: queued_ false, wake_pending_ true, nothing running and nothing
|
||||
// scheduled. The node sleeps with a wake outstanding, which is precisely the
|
||||
// invariant that commit set out to establish. It is not a memory-ordering
|
||||
// subtlety — the interleaving above holds under seq_cst.
|
||||
//
|
||||
// It survived because every caller happened to follow release_and_recheck()
|
||||
// with a level re-check (on_input_ready(), or outputs_have_space() on the
|
||||
// parked path), which rediscovers the state a lost wake would have signalled.
|
||||
// That is a property of the call sites, not of the mechanism, and any new early
|
||||
// return that forgets the re-check turns it back into a hang.
|
||||
//
|
||||
// One atomic with three states makes the race unrepresentable: "idle" and "wake
|
||||
// outstanding" are the same variable, so no interleaving can produce both.
|
||||
//
|
||||
// Idle nothing in flight
|
||||
// Queued a firing is in flight or queued; no wake since it was claimed
|
||||
// QueuedWake a firing is in flight or queued, and a wake arrived meanwhile
|
||||
//
|
||||
class SubmitGate {
|
||||
public:
|
||||
/// Register a wake. Returns true when the caller must submit the node;
|
||||
/// false when a firing is already in flight and the wake has been recorded
|
||||
/// against it instead.
|
||||
bool claim() noexcept {
|
||||
int cur = state_.load(std::memory_order_acquire);
|
||||
for (;;) {
|
||||
if (cur == kIdle) {
|
||||
if (state_.compare_exchange_weak(cur, kQueued,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return true;
|
||||
} else if (cur == kQueued) {
|
||||
if (state_.compare_exchange_weak(cur, kQueuedWake,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return false;
|
||||
} else {
|
||||
return false; // a wake is already recorded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End the in-flight firing. Returns true when a wake arrived during it and
|
||||
/// the caller must submit again — in which case the gate stays claimed, so
|
||||
/// the node is handed straight from one firing to the next and is never
|
||||
/// momentarily idle with work outstanding. Returns false when the node is
|
||||
/// now idle.
|
||||
bool release() noexcept {
|
||||
int cur = state_.load(std::memory_order_acquire);
|
||||
for (;;) {
|
||||
if (cur == kQueuedWake) {
|
||||
if (state_.compare_exchange_weak(cur, kQueued,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return true;
|
||||
} else {
|
||||
// kQueued, or kIdle if a stop already forced the gate down.
|
||||
if (state_.compare_exchange_weak(cur, kIdle,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the claim and any recorded wake. For stop paths only: honouring a
|
||||
/// wake there would resubmit a dead node.
|
||||
void force_idle() noexcept { state_.store(kIdle, std::memory_order_release); }
|
||||
|
||||
bool queued() const noexcept { return state_.load(std::memory_order_relaxed) != kIdle; }
|
||||
bool wake_pending() const noexcept { return state_.load(std::memory_order_relaxed) == kQueuedWake; }
|
||||
|
||||
private:
|
||||
static constexpr int kIdle = 0;
|
||||
static constexpr int kQueued = 1;
|
||||
static constexpr int kQueuedWake = 2;
|
||||
|
||||
std::atomic<int> state_{kIdle};
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
@@ -97,4 +97,41 @@ struct repeat_tuple<T, N, std::index_sequence<Is...>> {
|
||||
template<typename T, std::size_t N>
|
||||
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
|
||||
|
||||
// ── 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 message types that 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.
|
||||
//
|
||||
// Lives here rather than in pool_node.hpp because every node type that forwards
|
||||
// values needs it, not just the pool-scheduled ones. FilterNode and RouterNode
|
||||
// not having it is what let an EOF token be dropped on a full output.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace kpn
|
||||
|
||||
@@ -55,6 +55,8 @@ class IVariantChannel {
|
||||
public:
|
||||
virtual ~IVariantChannel() = default;
|
||||
virtual void push(Variant v) = 0;
|
||||
// Lossless push with backpressure (waits instead of dropping when full).
|
||||
virtual void push_blocking(Variant v) = 0;
|
||||
virtual Variant pop() = 0;
|
||||
virtual std::type_index type_index() const = 0;
|
||||
virtual std::string type_name() const = 0;
|
||||
@@ -76,6 +78,9 @@ public:
|
||||
void push(Variant v) override {
|
||||
channel_->push(std::get<T>(std::move(v)));
|
||||
}
|
||||
void push_blocking(Variant v) override {
|
||||
channel_->push_blocking(std::get<T>(std::move(v)));
|
||||
}
|
||||
Variant pop() override {
|
||||
return Variant{ channel_->pop() };
|
||||
}
|
||||
@@ -157,6 +162,7 @@ public:
|
||||
|
||||
// ── INode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void prepare() override { node_.prepare(); }
|
||||
void start() override { node_.start(); }
|
||||
void stop() override { node_.stop(); }
|
||||
bool running() const override { return node_.running(); }
|
||||
|
||||
@@ -65,6 +65,11 @@ static std::string to_json(const std::vector<NodeSnapshot>& nodes,
|
||||
<< ",\"fps\":" << n.throughput_fps
|
||||
<< ",\"total_cpu_ms\":" << n.total_cpu_ms
|
||||
<< ",\"cpu_util_pct\":" << n.cpu_util_pct
|
||||
// Scheduling state — lets a WEDGED pipeline be interrogated over HTTP
|
||||
// without a debugger, which matters because the lost-wake bug does not
|
||||
// reproduce under one. See NodeSnapshot for how to read the pair.
|
||||
<< ",\"queued\":" << (n.queued ? "true" : "false")
|
||||
<< ",\"wake_pending\":" << (n.wake_pending ? "true" : "false")
|
||||
<< "}";
|
||||
}
|
||||
o << "],\"edges\":[";
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
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
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the PERF_PLAN Phase-0 acceptance criterion.
|
||||
|
||||
Runs bench_pipeline several times and reports, per row, how far the passes
|
||||
spread around their median. The plan's gate is: the same configuration run 7x
|
||||
lands within +/-5% on every row. Until that holds, no measured difference
|
||||
between KPN and TBB is worth acting on.
|
||||
|
||||
Exits non-zero if any row exceeds the tolerance, so it can gate a session of
|
||||
performance work rather than merely inform one.
|
||||
|
||||
A full sweep is hours, so the run is observable and restartable rather than
|
||||
opaque: rows stream to --out-dir as each pass produces them, and a progress
|
||||
bar tracks rows within the pass. Killing the run keeps everything already
|
||||
written; --resume picks up from the completed passes on disk.
|
||||
|
||||
Usage:
|
||||
scripts/bench_repro_check.py ./build_bench/benchmarks/bench_pipeline \\
|
||||
--passes 7 --tolerance 5 -- --work=10 --topos=chain,wide --reps=5
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
KEY_COLS = ("topology", "size", "work_us", "threads")
|
||||
|
||||
# bench_pipeline reports throughput, bench_dispatch reports per-dispatch cost.
|
||||
# Either is a valid thing to demand reproducibility of; deviation from the
|
||||
# median is symmetric, so it does not matter which direction is "better".
|
||||
METRIC_COLS = ("items_per_sec", "ns_per_dispatch")
|
||||
|
||||
|
||||
def _progress(total, desc):
|
||||
"""A tqdm bar if tqdm is installed, else a minimal stderr fallback.
|
||||
|
||||
The fallback exists because this script gates a benchmark run; refusing to
|
||||
start over a missing progress dependency would be the wrong trade.
|
||||
"""
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except ImportError:
|
||||
class Fallback:
|
||||
def __init__(self):
|
||||
self.n = 0
|
||||
|
||||
def update(self, k=1):
|
||||
self.n += k
|
||||
end = "\n" if (total and self.n >= total) else "\r"
|
||||
print(f" {desc}: {self.n}/{total or '?'} rows",
|
||||
file=sys.stderr, end=end, flush=True)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
return Fallback()
|
||||
|
||||
return tqdm(total=total, desc=desc, unit="row", leave=False,
|
||||
bar_format=" {desc}: {n_fmt}/{total_fmt} rows "
|
||||
"|{bar}| {elapsed}<{remaining}",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
def parse_csv_lines(lines, metric=None):
|
||||
"""Return ({(topology, size, work_us, threads): value}, metric_name)."""
|
||||
rows = {}
|
||||
header = None
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
fields = line.split(",")
|
||||
if header is None:
|
||||
if fields[0] != "topology":
|
||||
continue
|
||||
header = fields
|
||||
if metric is None:
|
||||
for cand in METRIC_COLS:
|
||||
if cand in header:
|
||||
metric = cand
|
||||
break
|
||||
else:
|
||||
sys.exit(f"no metric column found in header: {header}")
|
||||
elif metric not in header:
|
||||
sys.exit(f"metric {metric!r} not in header: {header}")
|
||||
continue
|
||||
rec = dict(zip(header, fields))
|
||||
try:
|
||||
key = tuple(rec[c] for c in KEY_COLS)
|
||||
rows[key] = float(rec[metric])
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
return rows, metric
|
||||
|
||||
|
||||
def parse_csv(text, metric=None):
|
||||
return parse_csv_lines(text.splitlines(), metric)
|
||||
|
||||
|
||||
def count_rows(binary, extra):
|
||||
"""Enumerate the sweep cheaply, so the progress bar has a real total.
|
||||
|
||||
Asks the binary itself rather than reimplementing the sweep in Python,
|
||||
which would silently drift from the C++ defaults. Returns None if the
|
||||
probe fails -- an unknown total degrades the bar, it does not stop the run.
|
||||
"""
|
||||
probe = [binary] + extra + ["--reps=0", "--warmup=0"]
|
||||
try:
|
||||
proc = subprocess.run(probe, capture_output=True, text=True,
|
||||
timeout=600)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
rows, _ = parse_csv(proc.stdout)
|
||||
return len(rows) or None
|
||||
|
||||
|
||||
def run_pass(binary, extra, total, desc, sink, metric):
|
||||
"""Run one pass, streaming rows to `sink` and the bar as they arrive.
|
||||
|
||||
capture_output would withhold every row until the pass ended, which for a
|
||||
multi-hour sweep means no way to tell a slow run from a wedged one.
|
||||
"""
|
||||
lines = []
|
||||
bar = _progress(total, desc)
|
||||
proc = subprocess.Popen([binary] + extra, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, bufsize=1)
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
lines.append(line)
|
||||
if sink:
|
||||
sink.write(line)
|
||||
sink.flush() # a killed run keeps its rows
|
||||
stripped = line.strip()
|
||||
if (stripped and not stripped.startswith("#")
|
||||
and "," in stripped
|
||||
and not stripped.startswith("topology,")):
|
||||
bar.update(1)
|
||||
finally:
|
||||
bar.close()
|
||||
proc.stdout.close()
|
||||
stderr = proc.stderr.read()
|
||||
proc.stderr.close()
|
||||
rc = proc.wait()
|
||||
|
||||
if rc != 0:
|
||||
print(stderr, file=sys.stderr)
|
||||
sys.exit(f"{binary} failed with {rc}")
|
||||
return parse_csv_lines(lines, metric)
|
||||
|
||||
|
||||
def report(passes, metric, tolerance, npasses):
|
||||
keys = set(passes[0])
|
||||
for p in passes[1:]:
|
||||
keys &= set(p)
|
||||
if not keys:
|
||||
sys.exit("no rows common to every pass")
|
||||
|
||||
print(f"\n{'row':<34} {'median ' + metric:>20} {'worst dev':>10} verdict")
|
||||
print("-" * 72)
|
||||
|
||||
failures = 0
|
||||
for key in sorted(keys):
|
||||
values = [p[key] for p in passes]
|
||||
med = statistics.median(values)
|
||||
worst = max(abs(v - med) / med * 100 for v in values) if med else 0.0
|
||||
ok = worst <= tolerance
|
||||
failures += not ok
|
||||
label = "{}-{} w={} s={}".format(*key)
|
||||
print(f"{label:<34} {med:>20.1f} {worst:>9.1f}% {'ok' if ok else 'NOISY'}")
|
||||
|
||||
print("-" * 72)
|
||||
if failures:
|
||||
print(f"{failures}/{len(keys)} rows exceed +/-{tolerance:g}% — "
|
||||
f"the Phase-0 gate is not met.")
|
||||
return 1
|
||||
print(f"all {len(keys)} rows within +/-{tolerance:g}% "
|
||||
f"over {npasses} passes — Phase-0 gate met.")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("binary", help="path to bench_pipeline")
|
||||
ap.add_argument("--passes", type=int, default=7)
|
||||
ap.add_argument("--tolerance", type=float, default=5.0,
|
||||
help="max allowed deviation from the median, percent")
|
||||
ap.add_argument("--metric", default=None, choices=METRIC_COLS,
|
||||
help="column to check (default: whichever the CSV carries)")
|
||||
ap.add_argument("--out-dir", default=None,
|
||||
help="write pass-NN.csv as rows arrive "
|
||||
"(default: bench_runs/<timestamp>)")
|
||||
ap.add_argument("--resume", action="store_true",
|
||||
help="reuse complete pass-NN.csv files in --out-dir")
|
||||
|
||||
# Everything after a standalone `--` goes to bench_pipeline verbatim.
|
||||
# argparse.REMAINDER would swallow this script's own flags instead.
|
||||
argv = sys.argv[1:]
|
||||
extra = []
|
||||
if "--" in argv:
|
||||
cut = argv.index("--")
|
||||
argv, extra = argv[:cut], argv[cut + 1:]
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
out_dir = args.out_dir
|
||||
if out_dir is None:
|
||||
if args.resume:
|
||||
sys.exit("--resume needs an explicit --out-dir")
|
||||
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out_dir = os.path.join("bench_runs", stamp)
|
||||
out = pathlib.Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total = count_rows(args.binary, extra)
|
||||
print(f"writing to {out}/", file=sys.stderr)
|
||||
if total:
|
||||
print(f"{total} rows per pass, {args.passes} passes", file=sys.stderr)
|
||||
|
||||
passes = []
|
||||
metric = args.metric
|
||||
for i in range(args.passes):
|
||||
path = out / f"pass-{i + 1:02d}.csv"
|
||||
|
||||
if args.resume and path.exists():
|
||||
rows, metric = parse_csv(path.read_text(), metric)
|
||||
# A partial file from a killed run must not be silently averaged
|
||||
# in as if it were a whole pass.
|
||||
if total and len(rows) < total:
|
||||
print(f"pass {i + 1}/{args.passes}: {path.name} has "
|
||||
f"{len(rows)}/{total} rows — rerunning", file=sys.stderr)
|
||||
else:
|
||||
print(f"pass {i + 1}/{args.passes}: reusing {path.name} "
|
||||
f"({len(rows)} rows)", file=sys.stderr)
|
||||
passes.append(rows)
|
||||
continue
|
||||
|
||||
print(f"pass {i + 1}/{args.passes} ...", file=sys.stderr, flush=True)
|
||||
with open(path, "w") as sink:
|
||||
rows, metric = run_pass(args.binary, extra, total,
|
||||
f"pass {i + 1}/{args.passes}", sink, metric)
|
||||
passes.append(rows)
|
||||
|
||||
return report(passes, metric, args.tolerance, args.passes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,9 +6,9 @@ Directive format (in README.md.in):
|
||||
<!-- @snippet path/to/file.cpp snippet_name -->
|
||||
|
||||
Snippet tags (in C++ source files):
|
||||
// [snippet: snippet_name]
|
||||
// --8<-- [start:snippet_name]
|
||||
...content...
|
||||
// [/snippet: snippet_name]
|
||||
// --8<-- [end:snippet_name]
|
||||
|
||||
In Python files use # instead of //.
|
||||
"""
|
||||
@@ -33,8 +33,8 @@ def comment_prefix(path: Path) -> str:
|
||||
|
||||
def extract_snippet(file_path: Path, name: str) -> str:
|
||||
prefix = comment_prefix(file_path)
|
||||
open_tag = f'{prefix} [snippet: {name}]'
|
||||
close_tag = f'{prefix} [/snippet: {name}]'
|
||||
open_tag = f'{prefix} --8<-- [start:{name}]'
|
||||
close_tag = f'{prefix} --8<-- [end:{name}]'
|
||||
|
||||
text = file_path.read_text()
|
||||
lines = text.splitlines()
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// network.cpp — orchestrator/watchdog implementation details.
|
||||
// network.cpp — orchestrator/watchdog implementation details. (CI: pipeline trigger)
|
||||
// Most of the Network class is header-only (template-heavy).
|
||||
// Non-template implementation lives here once the watchdog grows
|
||||
// beyond the stub in network.hpp.
|
||||
|
||||
+64
-1
@@ -34,7 +34,9 @@ add_executable(kpn_tests
|
||||
test_static_network.cpp
|
||||
test_shared_resource.cpp
|
||||
test_pool_node.cpp
|
||||
test_backpressure_deadlock.cpp
|
||||
test_scheduler.cpp
|
||||
test_submit_gate.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(kpn_tests PRIVATE
|
||||
@@ -43,6 +45,67 @@ target_link_libraries(kpn_tests PRIVATE
|
||||
GTest::gtest
|
||||
)
|
||||
|
||||
# ── Channel stress suite (separate executable) ────────────────────────────────
|
||||
# Contended SPSC tests for the lock-free Channel<T>. Kept out of kpn_tests
|
||||
# because each case runs many reps / tens of thousands of items and is slow.
|
||||
# Most valuable under -DKPN_SANITIZER=thread, but correct (and run) without it.
|
||||
add_executable(kpn_tests_stress test_channel_stress.cpp)
|
||||
target_link_libraries(kpn_tests_stress PRIVATE kpn Catch2::Catch2WithMain)
|
||||
|
||||
# ── Wedge soak (PERF_PLAN G1) ─────────────────────────────────────────────────
|
||||
# Long-running end-to-end loop over the configurations that historically wedged.
|
||||
# Always built, so it cannot rot, but its CTest cases are registered only under
|
||||
# -DKPN_ENABLE_SOAK_TESTS=ON: they run for minutes and would otherwise dominate
|
||||
# every `ctest` invocation. Performance work runs it before and after a change:
|
||||
#
|
||||
# cmake -B build -DKPN_ENABLE_SOAK_TESTS=ON -DKPN_SOAK_ITERS=50000
|
||||
# cmake --build build --target kpn_soak_wedge
|
||||
# ctest --test-dir build -L soak
|
||||
#
|
||||
# The binary self-diagnoses: an iteration that stops making progress trips a
|
||||
# watchdog that aborts naming the iteration and phase, rather than hanging.
|
||||
add_executable(kpn_soak_wedge soak_wedge.cpp)
|
||||
target_link_libraries(kpn_soak_wedge PRIVATE kpn)
|
||||
target_compile_options(kpn_soak_wedge PRIVATE -O2)
|
||||
|
||||
option(KPN_ENABLE_SOAK_TESTS "Register the wedge soak cases with CTest" OFF)
|
||||
set(KPN_SOAK_ITERS 5000 CACHE STRING "Iterations per wedge soak case")
|
||||
|
||||
# ── Sanitizer flags ───────────────────────────────────────────────────────────
|
||||
# kpn_sanitizer_flags() is defined in the top-level CMakeLists and is a no-op
|
||||
# unless -DKPN_SANITIZER=... is set. Sanitizer must be on both compile and link.
|
||||
kpn_sanitizer_flags(_kpn_san)
|
||||
if(_kpn_san)
|
||||
foreach(_t kpn_tests kpn_tests_stress kpn_soak_wedge)
|
||||
target_compile_options(${_t} PRIVATE ${_kpn_san})
|
||||
target_link_options(${_t} PRIVATE ${_kpn_san})
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
include(CTest)
|
||||
include(Catch)
|
||||
catch_discover_tests(kpn_tests)
|
||||
|
||||
# DISCOVERY_MODE PRE_TEST defers test enumeration to `ctest` run time. The
|
||||
# default (POST_BUILD) runs each test binary during the build to list its
|
||||
# cases — which fails a sanitizer build: a TSan/ASan binary needs a fixed
|
||||
# address-space layout and aborts on startup ("unexpected memory mapping")
|
||||
# under the container's ASLR, breaking the build before any test runs. The
|
||||
# tsan.yaml job invokes the binaries directly (not via ctest), so deferring
|
||||
# discovery costs nothing there and keeps `ctest` working for normal builds.
|
||||
catch_discover_tests(kpn_tests DISCOVERY_MODE PRE_TEST)
|
||||
# Register the stress suite under its own label so CI can run / time it
|
||||
# separately from the fast unit tests.
|
||||
catch_discover_tests(kpn_tests_stress DISCOVERY_MODE PRE_TEST PROPERTIES LABELS "stress")
|
||||
|
||||
if(KPN_ENABLE_SOAK_TESTS)
|
||||
# pool: the configuration the August wedges were reproduced on.
|
||||
add_test(NAME soak.wedge.pool
|
||||
COMMAND kpn_soak_wedge --mode=pool --depth=4 --threads=4
|
||||
--items=1000 --work-us=10 --iters=${KPN_SOAK_ITERS})
|
||||
# private: one pool per node — the model workstream A would change.
|
||||
add_test(NAME soak.wedge.private
|
||||
COMMAND kpn_soak_wedge --mode=priv --depth=8
|
||||
--items=1000 --work-us=10 --iters=${KPN_SOAK_ITERS})
|
||||
set_tests_properties(soak.wedge.pool soak.wedge.private PROPERTIES
|
||||
LABELS "soak" TIMEOUT 3600)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Wedge soak test (PERF_PLAN G1).
|
||||
//
|
||||
// Runs a pipeline configuration end-to-end in a loop and fails if any single
|
||||
// iteration stops making progress. Its purpose is to keep performance work
|
||||
// from silently reintroducing one of the wedges fixed in August 2026 — the
|
||||
// scheduler and channel wake paths are where both perf workstreams operate.
|
||||
//
|
||||
// Originally the minimal reproducer for the shared-pool chain wedge at
|
||||
// (chain, depth=4, work_us=10, pool_threads=4); the pre-6802328 code wedged
|
||||
// 5/5 within 45 s, at iterations 149, 1249, 332, 1740 and 493.
|
||||
//
|
||||
// A wedge is a hang, so a plain loop would hang CTest until its timeout with
|
||||
// no indication of where. The watchdog turns that into a failure naming the
|
||||
// iteration and the phase it stalled in.
|
||||
//
|
||||
// Usage: ./kpn_soak_wedge [options]
|
||||
// --iters=5000 iterations to run
|
||||
// --mode=pool|priv shared ThreadPool(--threads), or one private pool/node
|
||||
// --depth=4 chain depth
|
||||
// --threads=4 shared pool size (--mode=pool only)
|
||||
// --items=1000 items pushed per iteration
|
||||
// --work-us=10 busy-work per node
|
||||
// --watchdog-sec=30 per-iteration progress deadline
|
||||
|
||||
#include <kpn/kpn.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#if defined(__linux__)
|
||||
#include <sys/prctl.h>
|
||||
#endif
|
||||
|
||||
using namespace kpn;
|
||||
using sclock = std::chrono::steady_clock;
|
||||
|
||||
static std::atomic<int> g_work_us{10};
|
||||
|
||||
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<>>;
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── watchdog ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The worker bumps g_progress at every phase boundary. The watchdog aborts if
|
||||
// it stops moving, so a wedge is reported as a failure at a known iteration
|
||||
// rather than as an unattributable CTest timeout.
|
||||
|
||||
static std::atomic<unsigned long> g_progress{0};
|
||||
static std::atomic<int> g_iter{0};
|
||||
static std::atomic<const char*> g_phase{"init"};
|
||||
static std::atomic<bool> g_done{false};
|
||||
|
||||
static void mark(const char* phase) {
|
||||
g_phase.store(phase, std::memory_order_relaxed);
|
||||
g_progress.fetch_add(1, std::memory_order_release);
|
||||
}
|
||||
|
||||
static void watchdog(double deadline_sec) {
|
||||
unsigned long last = g_progress.load(std::memory_order_acquire);
|
||||
auto last_move = sclock::now();
|
||||
while (!g_done.load(std::memory_order_acquire)) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
unsigned long now = g_progress.load(std::memory_order_acquire);
|
||||
if (now != last) { last = now; last_move = sclock::now(); continue; }
|
||||
double stalled = std::chrono::duration<double>(sclock::now() - last_move).count();
|
||||
if (stalled > deadline_sec) {
|
||||
std::fprintf(stderr,
|
||||
"\nWEDGE: no progress for %.0fs at iteration %d, phase '%s'\n",
|
||||
stalled, g_iter.load(std::memory_order_relaxed),
|
||||
g_phase.load(std::memory_order_relaxed));
|
||||
std::fflush(stderr);
|
||||
std::abort(); // core dump / stack trace at the point of the wedge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── one iteration ─────────────────────────────────────────────────────────────
|
||||
|
||||
struct Opts {
|
||||
int iters = 5000;
|
||||
int depth = 4;
|
||||
int threads = 4;
|
||||
int items = 1000;
|
||||
int work_us = 10;
|
||||
bool shared_pool = true;
|
||||
double watchdog_sec = 30.0;
|
||||
};
|
||||
|
||||
static void one_round_pool(const Opts& o) {
|
||||
const std::size_t CAP = static_cast<std::size_t>(o.items);
|
||||
auto pool = std::make_shared<ThreadPool>(o.threads);
|
||||
|
||||
std::vector<std::shared_ptr<Channel<int>>> chs;
|
||||
for (int i = 0; i <= o.depth; ++i)
|
||||
chs.push_back(std::make_shared<Channel<int>>(CAP));
|
||||
|
||||
std::vector<std::unique_ptr<PoolChainNode>> nodes;
|
||||
for (int i = 0; i < o.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();
|
||||
mark("started");
|
||||
|
||||
std::thread reader([&] {
|
||||
for (int i = 0; i < o.items; ++i) chs.back()->pop();
|
||||
});
|
||||
std::thread pusher([&] {
|
||||
for (int i = 0; i < o.items; ++i) push_retry(*chs[0], i);
|
||||
});
|
||||
|
||||
pusher.join(); mark("pushed");
|
||||
reader.join(); mark("drained");
|
||||
for (auto& n : nodes) n->stop();
|
||||
mark("nodes stopped");
|
||||
pool->stop();
|
||||
mark("pool stopped");
|
||||
}
|
||||
|
||||
static void one_round_private(const Opts& o) {
|
||||
const std::size_t CAP = static_cast<std::size_t>(o.items);
|
||||
|
||||
std::vector<std::shared_ptr<Channel<int>>> chs;
|
||||
for (int i = 0; i <= o.depth; ++i)
|
||||
chs.push_back(std::make_shared<Channel<int>>(CAP));
|
||||
|
||||
std::vector<std::unique_ptr<ChainNode>> nodes;
|
||||
for (int i = 0; i < o.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();
|
||||
mark("started");
|
||||
|
||||
std::thread reader([&] {
|
||||
for (int i = 0; i < o.items; ++i) chs.back()->pop();
|
||||
});
|
||||
std::thread pusher([&] {
|
||||
for (int i = 0; i < o.items; ++i) push_retry(*chs[0], i);
|
||||
});
|
||||
|
||||
pusher.join(); mark("pushed");
|
||||
reader.join(); mark("drained");
|
||||
for (auto& n : nodes) n->stop();
|
||||
mark("nodes stopped");
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void usage() {
|
||||
std::fprintf(stderr,
|
||||
"usage: kpn_soak_wedge [--iters=N] [--mode=pool|priv] [--depth=D]\n"
|
||||
" [--threads=T] [--items=N] [--work-us=U]\n"
|
||||
" [--watchdog-sec=S]\n");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
#if defined(__linux__)
|
||||
// Allow gdb to attach under ptrace_scope=1 when a wedge is caught.
|
||||
prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, 0, 0, 0);
|
||||
#endif
|
||||
|
||||
Opts o;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string a = argv[i];
|
||||
auto eq = a.find('=');
|
||||
std::string key = a.substr(0, eq);
|
||||
std::string val = eq == std::string::npos ? "" : a.substr(eq + 1);
|
||||
|
||||
if (key == "--iters") o.iters = std::atoi(val.c_str());
|
||||
else if (key == "--depth") o.depth = std::atoi(val.c_str());
|
||||
else if (key == "--threads") o.threads = std::atoi(val.c_str());
|
||||
else if (key == "--items") o.items = std::atoi(val.c_str());
|
||||
else if (key == "--work-us") o.work_us = std::atoi(val.c_str());
|
||||
else if (key == "--watchdog-sec") o.watchdog_sec = std::atof(val.c_str());
|
||||
else if (key == "--mode") o.shared_pool = (val != "priv");
|
||||
else { usage(); return 2; }
|
||||
}
|
||||
g_work_us.store(o.work_us, std::memory_order_relaxed);
|
||||
|
||||
std::fprintf(stderr,
|
||||
"soak: mode=%s depth=%d threads=%d items=%d work_us=%d iters=%d watchdog=%.0fs\n",
|
||||
o.shared_pool ? "pool" : "priv", o.depth,
|
||||
o.shared_pool ? o.threads : o.depth, o.items, o.work_us,
|
||||
o.iters, o.watchdog_sec);
|
||||
|
||||
std::thread wd(watchdog, o.watchdog_sec);
|
||||
|
||||
const auto t0 = sclock::now();
|
||||
for (int i = 0; i < o.iters; ++i) {
|
||||
g_iter.store(i, std::memory_order_relaxed);
|
||||
if (o.shared_pool) one_round_pool(o);
|
||||
else one_round_private(o);
|
||||
if ((i + 1) % 100 == 0) {
|
||||
std::fprintf(stderr, "\r %d/%d", i + 1, o.iters);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
g_done.store(true, std::memory_order_release);
|
||||
wd.join();
|
||||
|
||||
double secs = std::chrono::duration<double>(sclock::now() - t0).count();
|
||||
std::fprintf(stderr, "\ncompleted %d iterations in %.1fs with no wedge\n",
|
||||
o.iters, secs);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// Regression: a blocking push must not park a pool worker.
|
||||
//
|
||||
// Node outputs use push_blocking so a full channel costs time rather than data
|
||||
// (a dropped frame does not degrade a downstream result, it silently changes
|
||||
// one). But push_blocking sleeps *inside* fire_once, which runs on a pool
|
||||
// worker — and nodes are pinned to workers by index. Park enough workers in
|
||||
// that retry loop and there is nobody left to run the consumer that would drain
|
||||
// the channel, so the whole chain wedges.
|
||||
//
|
||||
// This is the failure channel.hpp:174 already warns about for sentinels
|
||||
// ("a blocking push would park that thread and stop it draining its own input,
|
||||
// cascading into a hold-and-wait deadlock under backpressure"). The warning
|
||||
// applies to data pushes too.
|
||||
//
|
||||
// Observed in the field as an intermittent hang: frame_source, camera_pos,
|
||||
// face_detector and face_aligner all asleep in push_blocking at once.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <kpn/kpn.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace {
|
||||
|
||||
struct Produce {
|
||||
static constexpr std::string_view label() { return "produce"; }
|
||||
int n{0};
|
||||
int operator()() { return n++; }
|
||||
};
|
||||
|
||||
struct Relay {
|
||||
static constexpr std::string_view label() { return "relay"; }
|
||||
int operator()(int v) { return v; }
|
||||
};
|
||||
|
||||
// Deliberately slower than the producer, so the channels between them fill.
|
||||
struct SlowSink {
|
||||
static constexpr std::string_view label() { return "slow_sink"; }
|
||||
std::atomic<int>* seen;
|
||||
void operator()(int) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
seen->fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a saturated chain keeps draining", "[backpressure][deadlock]") {
|
||||
std::atomic<int> seen{0};
|
||||
|
||||
Produce p_fn;
|
||||
Relay r1_fn, r2_fn, r3_fn;
|
||||
SlowSink s_fn{&seen};
|
||||
|
||||
// Small channels so they saturate immediately, and a chain longer than a
|
||||
// modest pool — the shape that starves workers.
|
||||
kpn::ObjectNode<Produce, kpn::in<>, kpn::out<"a">, "produce", 0> p (p_fn, 2);
|
||||
kpn::ObjectNode<Relay, kpn::in<"a">, kpn::out<"b">, "relay1", 0> r1(r1_fn, 2);
|
||||
kpn::ObjectNode<Relay, kpn::in<"b">, kpn::out<"c">, "relay2", 0> r2(r2_fn, 2);
|
||||
kpn::ObjectNode<Relay, kpn::in<"c">, kpn::out<"d">, "relay3", 0> r3(r3_fn, 2);
|
||||
kpn::ObjectNode<SlowSink, kpn::in<"d">, kpn::out<>, "slow_sink", 0> s (s_fn, 2);
|
||||
|
||||
auto net = kpn::make_network(
|
||||
kpn::edge(p.output<"a">(), r1.input<"a">()),
|
||||
kpn::edge(r1.output<"b">(), r2.input<"b">()),
|
||||
kpn::edge(r2.output<"c">(), r3.input<"c">()),
|
||||
kpn::edge(r3.output<"d">(), s.input<"d">())
|
||||
);
|
||||
net.start();
|
||||
|
||||
// The sink is the slowest stage at 2 ms/item, so 40 items is ~80 ms of real
|
||||
// work. Anything approaching the timeout means the chain stopped draining
|
||||
// rather than merely running slowly.
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(20);
|
||||
while (seen.load(std::memory_order_relaxed) < 40 &&
|
||||
std::chrono::steady_clock::now() < deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
|
||||
const int got = seen.load(std::memory_order_relaxed);
|
||||
net.stop();
|
||||
|
||||
INFO("items drained: " << got << " of 40");
|
||||
CHECK(got >= 40);
|
||||
}
|
||||
|
||||
// Regression: a saturated chain must never stall.
|
||||
//
|
||||
// push_outputs parks from two places: the retry at the top of fire_once, and
|
||||
// the ordinary push after the node function returns. Both release the worker,
|
||||
// so both face the same lost wakeup — a space callback firing while queued_ is
|
||||
// still up is swallowed by try_submit's CAS. Only the retry path re-checked for
|
||||
// space afterwards. The normal path fell through to on_input_ready(), which
|
||||
// resubmits only if inputs are ready — and the firing that just parked had
|
||||
// consumed its input, so they are not.
|
||||
//
|
||||
// The strand is permanent under saturation: the node holds its value, its
|
||||
// consumer waits for exactly that value, and its producer fills the node's
|
||||
// input channel and parks too. Nothing moves again.
|
||||
//
|
||||
// The test above cannot catch it — 40 items drain before any strand occurs.
|
||||
// This one runs the chain saturated and watches for progress to *freeze*, which
|
||||
// is the signature of the deadlock. It deliberately does not assert a total:
|
||||
// capacity-1 channels are slow, and "slow" must never be reported as "wedged".
|
||||
namespace {
|
||||
|
||||
struct FreeRun {
|
||||
static constexpr std::string_view label() { return "free_run"; }
|
||||
int n{0};
|
||||
int operator()() { return n++; }
|
||||
};
|
||||
|
||||
struct CountingSink {
|
||||
static constexpr std::string_view label() { return "counting_sink"; }
|
||||
std::atomic<int>* seen;
|
||||
void operator()(int) { seen->fetch_add(1, std::memory_order_relaxed); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a saturated chain never stalls", "[backpressure][deadlock]") {
|
||||
std::atomic<int> seen{0};
|
||||
|
||||
FreeRun p_fn;
|
||||
Relay r1_fn, r2_fn;
|
||||
CountingSink s_fn{&seen};
|
||||
|
||||
// Capacity 1 everywhere: every push contends, so the park path is taken
|
||||
// constantly and the race window is sampled millions of times.
|
||||
kpn::ObjectNode<FreeRun, kpn::in<>, kpn::out<"a">, "free_run", 0> p (p_fn, 1);
|
||||
kpn::ObjectNode<Relay, kpn::in<"a">, kpn::out<"b">, "relay1", 0> r1(r1_fn, 1);
|
||||
kpn::ObjectNode<Relay, kpn::in<"b">, kpn::out<"c">, "relay2", 0> r2(r2_fn, 1);
|
||||
kpn::ObjectNode<CountingSink, kpn::in<"c">, kpn::out<>, "sink", 0> s (s_fn, 1);
|
||||
|
||||
auto net = kpn::make_network(
|
||||
kpn::edge(p.output<"a">(), r1.input<"a">()),
|
||||
kpn::edge(r1.output<"b">(), r2.input<"b">()),
|
||||
kpn::edge(r2.output<"c">(), s.input<"c">())
|
||||
);
|
||||
net.start();
|
||||
|
||||
// A live chain moves thousands of items a second, so 3 s with no movement
|
||||
// at all is a wedge, not a slow patch. Sampling for 25 s gives the race
|
||||
// ample opportunity: the pipeline hit it roughly twice in 30 runs.
|
||||
const auto giveup = std::chrono::steady_clock::now() + std::chrono::seconds(25);
|
||||
int last = 0;
|
||||
auto last_move = std::chrono::steady_clock::now();
|
||||
bool stalled = false;
|
||||
int stall_at = 0;
|
||||
|
||||
while (std::chrono::steady_clock::now() < giveup) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
const int now_seen = seen.load(std::memory_order_relaxed);
|
||||
if (now_seen != last) {
|
||||
last = now_seen;
|
||||
last_move = std::chrono::steady_clock::now();
|
||||
} else if (std::chrono::steady_clock::now() - last_move >
|
||||
std::chrono::seconds(3)) {
|
||||
stalled = true;
|
||||
stall_at = now_seen;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
net.stop();
|
||||
|
||||
INFO("chain stalled after " << stall_at << " items");
|
||||
CHECK_FALSE(stalled);
|
||||
// Guard against the test passing because nothing ever ran.
|
||||
CHECK(last > 1000);
|
||||
}
|
||||
|
||||
// Regression: a node must not start with a wake already outstanding.
|
||||
//
|
||||
// 9c5ce5f established the invariant for the running pipeline — a node never
|
||||
// sleeps with a wake it dropped. start() broke the same invariant before the
|
||||
// pipeline was even running:
|
||||
//
|
||||
// enable_inputs(...); // channel goes live here
|
||||
// stop_flag_.store(false);
|
||||
// queued_.store(false);
|
||||
// register_callbacks(...); // push callback installed here
|
||||
//
|
||||
// StaticNetwork starts nodes sources-first, so an upstream node is already
|
||||
// firing into this one during that gap. A push landing there is accepted by the
|
||||
// ring but wakes nobody: Channel::push invokes push_callback_ only on the
|
||||
// empty→non-empty transition, and at that instant the callback is null. Every
|
||||
// later push sees a non-empty ring and stays silent. The node is never
|
||||
// submitted, and since a sink has no outputs there is no space callback to
|
||||
// rescue it either.
|
||||
//
|
||||
// The signature is distinctive: **zero** items delivered, not a stall partway.
|
||||
// The chain reads as wedged from the first frame. Under `ctest -j4` on a loaded
|
||||
// machine it reproduced 7 times in 24, and never once in 10 unloaded runs —
|
||||
// contention widens the window between those two statements. That is almost
|
||||
// certainly the "rare hang, ~1 run in 20 at a 300 s timeout" 28e0667 recorded as
|
||||
// known-incomplete.
|
||||
//
|
||||
// This test needs no contention: it constructs the state the race leaves behind
|
||||
// directly, by enabling the input and pushing before start() is ever called.
|
||||
namespace {
|
||||
|
||||
int passthrough(int x) { return x; }
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a node started with data already queued still fires",
|
||||
"[backpressure][startup]") {
|
||||
auto pool = std::make_shared<kpn::ThreadPool>(2);
|
||||
pool->start();
|
||||
|
||||
auto node = kpn::make_pool_node<passthrough>(pool, 8);
|
||||
kpn::Channel<int> out_ch(8);
|
||||
node.set_output_channel<0>(&out_ch);
|
||||
|
||||
// The missed edge: the channel is live and already holds a value, but no
|
||||
// callback was installed when it arrived, so the wake has been and gone.
|
||||
node.input_channel<0>().enable();
|
||||
node.input_channel<0>().push(21);
|
||||
|
||||
node.start();
|
||||
|
||||
// Bounded wait — a plain pop() would hang rather than fail on a regression.
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||
while (out_ch.size() == 0 && std::chrono::steady_clock::now() < deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
|
||||
const bool delivered = out_ch.size() > 0;
|
||||
const int got = delivered ? out_ch.pop() : -1;
|
||||
|
||||
node.stop();
|
||||
pool->stop();
|
||||
|
||||
INFO("value delivered: " << got);
|
||||
REQUIRE(delivered);
|
||||
CHECK(got == 21);
|
||||
}
|
||||
|
||||
// Regression: a fanout absorbs an unequal pair of consumers by slowing, not by
|
||||
// dropping.
|
||||
//
|
||||
// 6595e6e made node outputs lossless and 28e0667 stopped them parking a worker,
|
||||
// but FanoutNode was in neither: it kept `catch (ChannelOverflowError&) {}` per
|
||||
// output, so whichever branch fell behind lost items — silently, and by an
|
||||
// amount that depended on timing. Two runs of the same input could therefore
|
||||
// disagree, which is fatal for a fixture the rest of the suite is scored
|
||||
// against.
|
||||
//
|
||||
// The two assertions are the two halves of the requirement:
|
||||
// - no gaps: the slow branch receives *every* item, not most of them;
|
||||
// - bounded lead: the fast branch is throttled to the slow one rather than
|
||||
// racing ahead over a drain that is quietly discarding the difference.
|
||||
//
|
||||
// Either alone would pass on a broken implementation. A fanout that pushed only
|
||||
// to the slow branch has no gaps; one that dropped everything for the slow
|
||||
// branch keeps a bounded lead by never letting it fall behind.
|
||||
namespace {
|
||||
|
||||
// Records the sequence it sees, so a dropped item shows up as a gap rather than
|
||||
// merely as a smaller total.
|
||||
struct SeqCheck {
|
||||
std::atomic<int>* next_expected;
|
||||
std::atomic<bool>* saw_gap;
|
||||
int delay_us{0};
|
||||
|
||||
void record(int v) const {
|
||||
if (delay_us)
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(delay_us));
|
||||
const int want = next_expected->load(std::memory_order_relaxed);
|
||||
if (v != want) saw_gap->store(true, std::memory_order_relaxed);
|
||||
else next_expected->store(want + 1, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
struct FastBranch : SeqCheck {
|
||||
static constexpr std::string_view label() { return "fast_branch"; }
|
||||
void operator()(int v) { record(v); }
|
||||
};
|
||||
|
||||
struct SlowBranch : SeqCheck {
|
||||
static constexpr std::string_view label() { return "slow_branch"; }
|
||||
void operator()(int v) { record(v); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a fanout absorbs an unequal pair by slowing, not dropping",
|
||||
"[backpressure][fanout]") {
|
||||
std::atomic<int> fast_next{0}, slow_next{0};
|
||||
std::atomic<bool> fast_gap{false}, slow_gap{false};
|
||||
|
||||
FreeRun p_fn;
|
||||
FastBranch fast_fn{{&fast_next, &fast_gap, 0}};
|
||||
SlowBranch slow_fn{{&slow_next, &slow_gap, 500}}; // 0.5 ms/item
|
||||
|
||||
// Small channels so the slow branch saturates in the first few milliseconds
|
||||
// and stays saturated for the whole run.
|
||||
kpn::ObjectNode<FreeRun, kpn::in<>, kpn::out<"v">, "free_run", 0> p (p_fn, 8);
|
||||
kpn::ObjectNode<FastBranch, kpn::in<"fast">, kpn::out<>, "fast", 0> fa(fast_fn, 8);
|
||||
kpn::ObjectNode<SlowBranch, kpn::in<"slow">, kpn::out<>, "slow", 0> sl(slow_fn, 8);
|
||||
|
||||
// Two edges from one output port: make_network auto-inserts FanoutNode<int,2>.
|
||||
auto net = kpn::make_network(
|
||||
kpn::edge(p.output<"v">(), fa.input<"fast">()),
|
||||
kpn::edge(p.output<"v">(), sl.input<"slow">())
|
||||
);
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
net.stop();
|
||||
|
||||
const int fast_seen = fast_next.load(std::memory_order_relaxed);
|
||||
const int slow_seen = slow_next.load(std::memory_order_relaxed);
|
||||
|
||||
INFO("fast branch " << fast_seen << " items, slow branch " << slow_seen);
|
||||
CHECK_FALSE(fast_gap.load(std::memory_order_relaxed));
|
||||
CHECK_FALSE(slow_gap.load(std::memory_order_relaxed));
|
||||
// Guard against passing because nothing ran: 1 s at 0.5 ms/item is ~2000.
|
||||
CHECK(slow_seen > 200);
|
||||
// The lead is bounded by the buffering between the two — the fanout's own
|
||||
// input, the two output channels, and one item in each node's hand. A
|
||||
// dropping fanout has no such bound: the fast branch runs at full speed and
|
||||
// the difference is the loss.
|
||||
CHECK(fast_seen - slow_seen < 200);
|
||||
}
|
||||
|
||||
// Regression: a filter must not drop an EOF sentinel into a full output.
|
||||
//
|
||||
// RouterNode and FilterNode were the last nodes on a data path still using the
|
||||
// throwing push() and swallowing the result:
|
||||
//
|
||||
// try { out_ch_->push(val); } catch (const ChannelOverflowError&) {}
|
||||
//
|
||||
// 6595e6e made node outputs lossless, 28e0667 stopped them parking a worker,
|
||||
// a8cfe73 did the same for FanoutNode. These two were in none of them.
|
||||
//
|
||||
// For ordinary values that is the familiar silent-loss problem. For a sentinel
|
||||
// it is a hang. EOF is what tells every downstream node to shut down, and
|
||||
// nothing comes after it to retry — so a filter that passes EOF by predicate
|
||||
// but drops it by backpressure produces a pipeline that never terminates. The
|
||||
// scene-actor-extraction decimator is exactly this shape: `if (f.eof) return
|
||||
// true;` in the predicate, feeding a chain whose slowest node is an ONNX
|
||||
// embedder, so the output is reliably full at the moment EOF arrives.
|
||||
//
|
||||
// The test forces that state rather than racing for it: the sink is slow enough
|
||||
// that the filter's output channel is saturated for the whole run, so EOF meets
|
||||
// a full ring with certainty.
|
||||
//
|
||||
// Both assertions are needed. `saw_eof` alone would pass on an implementation
|
||||
// that dropped every ordinary value and delivered only the sentinel; `count`
|
||||
// alone would pass on the broken one, which delivers plenty of values and loses
|
||||
// only the token that matters.
|
||||
namespace {
|
||||
|
||||
struct EofFrame {
|
||||
int seq{0};
|
||||
bool eof{false};
|
||||
};
|
||||
|
||||
// EOF is emitted exactly once, as a real source does. Everything after it is a
|
||||
// filler frame the predicate rejects, which keeps the node alive without
|
||||
// re-offering the sentinel — a source that retried EOF would mask the bug,
|
||||
// since a later attempt could find the channel drained.
|
||||
struct EofSource {
|
||||
static constexpr std::string_view label() { return "eof_source"; }
|
||||
int n{0};
|
||||
int total{0};
|
||||
EofFrame operator()() {
|
||||
if (n > total) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
return {-1, false}; // filler: dropped by the predicate
|
||||
}
|
||||
EofFrame f{n, n == total};
|
||||
++n;
|
||||
return f;
|
||||
}
|
||||
};
|
||||
|
||||
struct EofSink {
|
||||
static constexpr std::string_view label() { return "eof_sink"; }
|
||||
std::atomic<int>* count;
|
||||
std::atomic<bool>* saw_eof;
|
||||
void operator()(EofFrame f) {
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(200));
|
||||
if (f.eof) saw_eof->store(true, std::memory_order_release);
|
||||
else count->fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a filter delivers EOF into a saturated output", "[backpressure][filter]") {
|
||||
std::atomic<int> count{0};
|
||||
std::atomic<bool> saw_eof{false};
|
||||
|
||||
EofSource src_fn{0, 40};
|
||||
EofSink sink_fn{&count, &saw_eof};
|
||||
|
||||
// Every real frame passes the predicate, so the only thing between source
|
||||
// and sink is backpressure. Small channels keep the output saturated.
|
||||
auto filt = kpn::make_filter<EofFrame>(
|
||||
[](const EofFrame& f) { return f.seq >= 0; }, 4);
|
||||
|
||||
kpn::ObjectNode<EofSource, kpn::in<>, kpn::out<"f">, "eof_source", 0> s(src_fn, 4);
|
||||
kpn::ObjectNode<EofSink, kpn::in<"f">, kpn::out<>, "eof_sink", 0> k(sink_fn, 4);
|
||||
|
||||
auto net = kpn::make_network(
|
||||
kpn::edge(s.output<"f">(), filt.input<0>()),
|
||||
kpn::edge(filt.output<0>(), k.input<"f">())
|
||||
);
|
||||
net.start();
|
||||
|
||||
// Generous relative to 41 frames at 200 us, and this is a liveness test:
|
||||
// the broken implementation never sets saw_eof no matter how long it runs.
|
||||
for (int i = 0; i < 200 && !saw_eof.load(std::memory_order_acquire); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
net.stop();
|
||||
|
||||
INFO("values delivered: " << count.load() << " of 40");
|
||||
CHECK(saw_eof.load(std::memory_order_acquire));
|
||||
CHECK(count.load(std::memory_order_relaxed) == 40);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
#include <string>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <kpn/channel.hpp>
|
||||
@@ -175,3 +176,161 @@ TEST_CASE("bandwidth_mbs returns 0 when elapsed_s is zero or negative", "[channe
|
||||
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
|
||||
}
|
||||
|
||||
// Regression: the sentinel slot holds one token and refuses a second.
|
||||
//
|
||||
// push_sentinel used to write eof_value_ unconditionally. Offering a second
|
||||
// token before the first was taken therefore did two wrong things at once: it
|
||||
// lost the first silently — and a lost EOF wedges every downstream pop forever
|
||||
// — and it wrote the storage while the consumer could be moving the previous
|
||||
// value out of it. For the shared_ptr storage that non-trivial types use, that
|
||||
// is a torn refcount, not merely a stale read.
|
||||
//
|
||||
// Refusing is correct rather than queueing: two control tokens on one channel
|
||||
// means the stream ended twice, which is a caller protocol error. Coalescing
|
||||
// them would hide it, and there is no second value that could sensibly follow
|
||||
// the end of a stream.
|
||||
TEST_CASE("a second sentinel is refused, not swallowed", "[channel][sentinel]") {
|
||||
Channel<int> ch(4);
|
||||
|
||||
REQUIRE(ch.push_sentinel(1));
|
||||
// Slot occupied: the first token is still undelivered.
|
||||
REQUIRE_FALSE(ch.push_sentinel(2));
|
||||
|
||||
// The first survives intact — the overwrite is what used to lose it.
|
||||
int out = 0;
|
||||
REQUIRE(ch.try_pop_now(out));
|
||||
CHECK(out == 1);
|
||||
|
||||
// And the slot is reusable once drained.
|
||||
REQUIRE(ch.push_sentinel(3));
|
||||
REQUIRE(ch.try_pop_now(out));
|
||||
CHECK(out == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("a refused sentinel is not counted as a drop", "[channel][sentinel]") {
|
||||
// A refusal means a token arrived while an equivalent one was already
|
||||
// pending — not that anything was lost. Counting it as a drop was wrong in
|
||||
// a way that showed up immediately on real content: a source at the end of
|
||||
// its input keeps being polled and keeps returning EOF, so the token is
|
||||
// re-offered on every firing, and the pipeline reported hundreds of dropped
|
||||
// frames on a clean run and exited non-zero.
|
||||
//
|
||||
// The delivery guarantee is unaffected: the first token is pending and will
|
||||
// arrive. Only the accounting changed.
|
||||
Channel<int> ch(4);
|
||||
REQUIRE(ch.push_sentinel(1));
|
||||
const auto before = ch.stats().drops.load();
|
||||
REQUIRE_FALSE(ch.push_sentinel(2));
|
||||
CHECK(ch.stats().drops.load() == before);
|
||||
|
||||
// And the one that was accepted is still the one delivered.
|
||||
int out = 0;
|
||||
REQUIRE(ch.try_pop_now(out));
|
||||
CHECK(out == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("try_push_sentinel leaves a refused value untouched", "[channel][sentinel]") {
|
||||
// The non-consuming form exists so a refused token is still the caller's to
|
||||
// report. The consuming push_sentinel cannot offer that, since the value is
|
||||
// already moved into its parameter.
|
||||
Channel<std::string> ch(4);
|
||||
std::string first = "eof-1", second = "eof-2";
|
||||
|
||||
REQUIRE(ch.try_push_sentinel(first) == Channel<std::string>::SentinelResult::Taken);
|
||||
REQUIRE(ch.try_push_sentinel(second) == Channel<std::string>::SentinelResult::SlotBusy);
|
||||
CHECK(second == "eof-2"); // not moved from
|
||||
|
||||
ch.disable();
|
||||
std::string third = "eof-3";
|
||||
CHECK(ch.try_push_sentinel(third) == Channel<std::string>::SentinelResult::Closed);
|
||||
CHECK(third == "eof-3");
|
||||
}
|
||||
|
||||
// Regression: try_push must distinguish delivered from discarded.
|
||||
//
|
||||
// It returned bool, and returned *true* for a closed channel — so "the value
|
||||
// arrived" and "the value was thrown away because nobody is listening" were the
|
||||
// same answer. Every caller was nonetheless correct, because both cases mean
|
||||
// "stop trying"; but nothing above the channel could tell the two apart, and a
|
||||
// producer counting successful pushes counted discards among them. Only the
|
||||
// channel's own drop counter knew, and only if someone read the diagnostics.
|
||||
TEST_CASE("try_push distinguishes taken, full and closed", "[channel]") {
|
||||
Channel<int> ch(2);
|
||||
int v = 1;
|
||||
|
||||
CHECK(ch.try_push(v) == Channel<int>::PushResult::Taken);
|
||||
CHECK(ch.try_push(v) == Channel<int>::PushResult::Taken);
|
||||
// Ring is full: the value is untouched and the caller keeps it.
|
||||
CHECK(ch.try_push(v) == Channel<int>::PushResult::Full);
|
||||
CHECK(v == 1);
|
||||
|
||||
ch.disable();
|
||||
const auto drops_before = ch.stats().drops.load();
|
||||
CHECK(ch.try_push(v) == Channel<int>::PushResult::Closed);
|
||||
// Discarded, and recorded as such rather than reported as a delivery.
|
||||
CHECK(ch.stats().drops.load() == drops_before + 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// Contended stress tests for the lock-free SPSC Channel<T>.
|
||||
//
|
||||
// The other channel tests (test_channel.cpp) are single-threaded or use a
|
||||
// single 20 ms sleep to order two threads — they never actually contend on the
|
||||
// ring, so they exercise neither the memory-ordering pairing nor the
|
||||
// spin/futex/lost-wakeup logic in pop().
|
||||
//
|
||||
// These tests are written to be run under ThreadSanitizer:
|
||||
//
|
||||
// cmake -B build -DKPN_SANITIZER=thread -DKPN_BUILD_EXAMPLES=OFF -DKPN_BUILD_PYTHON=OFF
|
||||
// cmake --build build --target kpn_tests_tsan
|
||||
// ./build/tests/kpn_tests_tsan
|
||||
//
|
||||
// They are also valid (and meaningful) without a sanitizer: the value/sequence
|
||||
// assertions catch lost or duplicated items regardless of build flags. TSan
|
||||
// adds detection of the underlying data race even on runs where the race did
|
||||
// not corrupt observable state.
|
||||
//
|
||||
// Channel<T> is SPSC: exactly one producer thread and one consumer thread per
|
||||
// channel. Every scenario below honours that contract.
|
||||
|
||||
#include <string>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <kpn/channel.hpp>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
using namespace kpn;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace {
|
||||
|
||||
// Repeat each scenario enough times that rare interleavings (spin window just
|
||||
// missing / just catching the next push, disable landing inside the futex
|
||||
// wait) actually occur across a run. Kept modest so a TSan run stays minutes,
|
||||
// not hours.
|
||||
constexpr int kReps = 200;
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("SPSC: every pushed item is popped exactly once, in order", "[channel][stress]") {
|
||||
// Small capacity forces frequent full/empty transitions, so both the
|
||||
// producer's overflow-retry and the consumer's spin->futex path are hit
|
||||
// many times. The producer retries on overflow rather than dropping, so
|
||||
// the consumer must observe a strictly contiguous 0..N-1 sequence.
|
||||
constexpr int N = 50'000;
|
||||
Channel<int> ch(/*capacity=*/4, /*spin_count=*/16);
|
||||
|
||||
std::thread producer([&] {
|
||||
for (int i = 0; i < N; ++i) {
|
||||
for (;;) {
|
||||
try { ch.push(i); break; }
|
||||
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
int expected = 0;
|
||||
bool in_order = true;
|
||||
for (int i = 0; i < N; ++i) {
|
||||
int v = ch.pop();
|
||||
if (v != expected) in_order = false;
|
||||
++expected;
|
||||
}
|
||||
producer.join();
|
||||
|
||||
REQUIRE(in_order);
|
||||
REQUIRE(expected == N);
|
||||
REQUIRE(ch.size() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("SPSC: tight empty<->non-empty transitions exercise spin/futex boundary",
|
||||
"[channel][stress]") {
|
||||
// spin_count=0 forces every empty pop() straight into atomic::wait, so this
|
||||
// hammers the lost-wakeup guard (snapshot wake_, re-check tail_, then wait).
|
||||
// The producer pushes one item then waits to go empty again, maximising the
|
||||
// number of empty->non-empty edges relative to item count.
|
||||
constexpr int N = 20'000;
|
||||
Channel<int> ch(/*capacity=*/2, /*spin_count=*/0);
|
||||
|
||||
std::thread producer([&] {
|
||||
for (int i = 0; i < N; ++i) {
|
||||
for (;;) {
|
||||
try { ch.push(i); break; }
|
||||
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
long sum = 0;
|
||||
for (int i = 0; i < N; ++i) sum += ch.pop();
|
||||
producer.join();
|
||||
|
||||
// Sum of 0..N-1 — detects any lost or duplicated item.
|
||||
REQUIRE(sum == static_cast<long>(N) * (N - 1) / 2);
|
||||
}
|
||||
|
||||
TEST_CASE("SPSC: disable() while consumer is blocked in pop() unblocks cleanly",
|
||||
"[channel][stress]") {
|
||||
// The data race of record: consumer blocked in pop() (spinning or parked in
|
||||
// the futex) while the owner thread calls disable(). pop() must observe the
|
||||
// close and throw ChannelClosedError — it must not hang and must not read
|
||||
// past the ring. Repeated so disable() lands at many points in pop()'s loop.
|
||||
for (int rep = 0; rep < kReps; ++rep) {
|
||||
Channel<int> ch(/*capacity=*/4, /*spin_count=*/8);
|
||||
std::atomic<bool> threw{false};
|
||||
std::atomic<bool> finished{false};
|
||||
|
||||
std::thread consumer([&] {
|
||||
try {
|
||||
ch.pop(); // empty channel: will block
|
||||
} catch (const ChannelClosedError&) {
|
||||
threw.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
finished.store(true, std::memory_order_relaxed);
|
||||
});
|
||||
|
||||
// Give the consumer a chance to reach the wait, then close.
|
||||
std::this_thread::sleep_for(50us);
|
||||
ch.disable();
|
||||
|
||||
consumer.join();
|
||||
REQUIRE(finished.load());
|
||||
REQUIRE(threw.load());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("SPSC: producer racing a disable() never throws and never hangs",
|
||||
"[channel][stress]") {
|
||||
// Mirror of the above from the producer side: push() racing disable() must
|
||||
// either enqueue or silently drop, never throw ChannelClosedError and never
|
||||
// wedge. Overflow is still a legal outcome (full accepting channel) and is
|
||||
// tolerated here.
|
||||
for (int rep = 0; rep < kReps; ++rep) {
|
||||
Channel<int> ch(/*capacity=*/8, /*spin_count=*/8);
|
||||
std::atomic<bool> bad{false};
|
||||
|
||||
std::thread producer([&] {
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
try { ch.push(i); }
|
||||
catch (const ChannelOverflowError&) { /* legal: full */ }
|
||||
catch (...) { bad.store(true, std::memory_order_relaxed); break; }
|
||||
}
|
||||
});
|
||||
|
||||
std::this_thread::sleep_for(20us);
|
||||
ch.disable(); // owner closes mid-stream
|
||||
producer.join();
|
||||
|
||||
REQUIRE_FALSE(bad.load());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("SPSC: push_callback fires for every push, never missed",
|
||||
"[channel][stress]") {
|
||||
// Regression: this callback is the *only* thing that wakes a PoolNode, and
|
||||
// it used to fire only on the empty->non-empty edge, computed from a head_
|
||||
// sampled before the item was published. A concurrent pop() could drain the
|
||||
// ring to empty in that window, so neither side saw the other: the item sat
|
||||
// in the ring with the consumer idle, and because the trigger was an edge it
|
||||
// never recovered. See set_push_callback in channel.hpp.
|
||||
//
|
||||
// The old version of this test asserted only `1 <= callbacks <= N`, which a
|
||||
// *missed* callback satisfies — it named the hazard and could not detect it.
|
||||
// One callback per successful push is the contract, so assert exactly that.
|
||||
Channel<int> ch(/*capacity=*/4, /*spin_count=*/4);
|
||||
std::atomic<int> callbacks{0};
|
||||
ch.set_push_callback([&] { callbacks.fetch_add(1, std::memory_order_relaxed); });
|
||||
|
||||
constexpr int N = 10'000;
|
||||
std::thread producer([&] {
|
||||
for (int i = 0; i < N; ++i) {
|
||||
for (;;) {
|
||||
try { ch.push(i); break; }
|
||||
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (int i = 0; i < N; ++i) (void)ch.pop();
|
||||
producer.join();
|
||||
|
||||
// Exactly one callback per successful push. Fewer means a wake was dropped,
|
||||
// which is the bug; more would mean a spurious wake was manufactured.
|
||||
REQUIRE(callbacks.load() == N);
|
||||
}
|
||||
|
||||
// Ordering contract of the out-of-band sentinel under contention.
|
||||
//
|
||||
// push_sentinel() publishes has_eof_ (release) after the producer's N ring
|
||||
// pushes; a consumer that observes has_eof_ (acquire) therefore also observes
|
||||
// every value pushed before it. Both pop() and try_pop_now() only surface the
|
||||
// sentinel once the ring is *freshly* observed empty, so the sentinel is the
|
||||
// strictly last item received — it never jumps ahead of a ring value pushed
|
||||
// before it. These tests treat the sentinel as a hard "last message" barrier
|
||||
// (the consumer stops draining the moment it sees it) and assert that all N
|
||||
// values arrived, in a contiguous 0..N-1 sequence, before it.
|
||||
//
|
||||
// Regression guard: an earlier version of pop() checked emptiness against a
|
||||
// stale tail_ snapshot from the top of its loop, so under load the sentinel
|
||||
// could surface with a few real values still queued — breaking in_order /
|
||||
// values==N here. Under TSan these also cover the has_eof_/eof_value_
|
||||
// acquire/release handshake and the spin/futex wakeup on push_sentinel().
|
||||
|
||||
TEST_CASE("SPSC: sentinel is strictly last, after every value (blocking pop)",
|
||||
"[channel][stress]") {
|
||||
constexpr int N = 20'000;
|
||||
constexpr int SENTINEL = -1;
|
||||
|
||||
for (int rep = 0; rep < kReps; ++rep) {
|
||||
// Small ring + tiny spin window so the ring is frequently empty exactly
|
||||
// when the sentinel is published — the interleaving under test.
|
||||
Channel<int> ch(/*capacity=*/4, /*spin_count=*/8);
|
||||
|
||||
std::thread producer([&] {
|
||||
for (int i = 0; i < N; ++i) {
|
||||
for (;;) {
|
||||
try { ch.push(i); break; }
|
||||
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
|
||||
}
|
||||
}
|
||||
ch.push_sentinel(SENTINEL); // must-deliver, never overflows/blocks
|
||||
});
|
||||
|
||||
int expected = 0;
|
||||
bool in_order = true;
|
||||
bool saw_sentinel = false;
|
||||
// Treat the sentinel as EOF: stop draining the instant it appears.
|
||||
for (;;) {
|
||||
int v = ch.pop();
|
||||
if (v == SENTINEL) { saw_sentinel = true; break; }
|
||||
if (v != expected) in_order = false;
|
||||
++expected;
|
||||
}
|
||||
producer.join();
|
||||
|
||||
REQUIRE(saw_sentinel);
|
||||
REQUIRE(in_order);
|
||||
REQUIRE(expected == N); // all N values received before the sentinel
|
||||
REQUIRE(ch.size() == 0);
|
||||
REQUIRE(ch.approx_size() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("SPSC: sentinel is strictly last, after every value (try_pop_now)",
|
||||
"[channel][stress]") {
|
||||
// The pool-node consume path is try_pop_now(), not pop(): it must surface
|
||||
// the out-of-band sentinel only once the ring is freshly observed empty.
|
||||
// The consumer spins with no sleeps, racing the producer at full tilt
|
||||
// across the empty-ring boundary where take_sentinel() is reached.
|
||||
constexpr int N = 20'000;
|
||||
constexpr int SENTINEL = -1;
|
||||
|
||||
for (int rep = 0; rep < kReps; ++rep) {
|
||||
Channel<int> ch(/*capacity=*/4, /*spin_count=*/0);
|
||||
|
||||
std::thread producer([&] {
|
||||
for (int i = 0; i < N; ++i) {
|
||||
for (;;) {
|
||||
try { ch.push(i); break; }
|
||||
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
|
||||
}
|
||||
}
|
||||
ch.push_sentinel(SENTINEL);
|
||||
});
|
||||
|
||||
int expected = 0;
|
||||
bool in_order = true;
|
||||
bool saw_sentinel = false;
|
||||
int v;
|
||||
for (;;) {
|
||||
if (!ch.try_pop_now(v)) { std::this_thread::yield(); continue; }
|
||||
if (v == SENTINEL) { saw_sentinel = true; break; }
|
||||
if (v != expected) in_order = false;
|
||||
++expected;
|
||||
}
|
||||
producer.join();
|
||||
|
||||
REQUIRE(saw_sentinel);
|
||||
REQUIRE(in_order);
|
||||
REQUIRE(expected == N);
|
||||
// Sentinel held no ring slot; once taken the channel is fully empty.
|
||||
REQUIRE(ch.size() == 0);
|
||||
REQUIRE(ch.approx_size() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Contended: a producer offering sentinels while the consumer takes them.
|
||||
//
|
||||
// The old push_sentinel wrote eof_value_ with no regard for whether the
|
||||
// consumer was reading it, so a second offer racing a take was a data race on
|
||||
// the storage — for the shared_ptr form used by non-trivial types, on the
|
||||
// refcount. Under TSan the old code reports it; the handshake added alongside
|
||||
// this test makes the producer's write conditional on observing the slot free,
|
||||
// which is what serialises the two.
|
||||
//
|
||||
// Payload is a std::string so the storage is the shared_ptr path rather than
|
||||
// the trivially-copyable one, and each token carries its own identity so a torn
|
||||
// value shows up as a mismatch rather than as a plausible-looking result.
|
||||
TEST_CASE("SPSC: offering sentinels concurrently with takes is race-free",
|
||||
"[channel][stress][sentinel]") {
|
||||
constexpr int kRounds = 20000;
|
||||
Channel<std::string> ch(4);
|
||||
|
||||
std::atomic<int> taken{0};
|
||||
std::atomic<bool> torn{false};
|
||||
std::atomic<bool> done{false};
|
||||
|
||||
std::thread consumer([&] {
|
||||
std::string out;
|
||||
while (!done.load(std::memory_order_acquire) || ch.approx_size() > 0) {
|
||||
if (ch.try_pop_now(out)) {
|
||||
if (out.rfind("eof-", 0) != 0) torn.store(true, std::memory_order_relaxed);
|
||||
taken.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
int accepted = 0;
|
||||
for (int i = 0; i < kRounds; ++i) {
|
||||
std::string tok = "eof-" + std::to_string(i);
|
||||
if (ch.try_push_sentinel(tok) == Channel<std::string>::SentinelResult::Taken)
|
||||
++accepted;
|
||||
}
|
||||
done.store(true, std::memory_order_release);
|
||||
consumer.join();
|
||||
|
||||
INFO("accepted " << accepted << " taken " << taken.load());
|
||||
CHECK_FALSE(torn.load(std::memory_order_relaxed));
|
||||
// Every accepted token must be delivered: the slot is refused while full,
|
||||
// so acceptance and delivery are one-to-one.
|
||||
CHECK(taken.load(std::memory_order_relaxed) == accepted);
|
||||
CHECK(accepted > 0);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <kpn/kpn.hpp>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
using namespace kpn;
|
||||
@@ -54,3 +58,93 @@ TEST_CASE("stop disables input channels — producer push is silently dropped",
|
||||
in_ch.push(99);
|
||||
REQUIRE(in_ch.size() == 0);
|
||||
}
|
||||
|
||||
// Regression: Network::set_error_handler must actually deliver the handler.
|
||||
//
|
||||
// The handler was stored in a member and never read. A node's exception was
|
||||
// discarded at the node boundary and the only surviving evidence was a Closed
|
||||
// event, which reports that a node stopped but not why — the difference between
|
||||
// a diagnosis and a guess. StaticNetwork has always wired this; Network
|
||||
// accepted the handler and silently dropped it, which is worse than not
|
||||
// offering the setter at all.
|
||||
//
|
||||
// The type changed with the fix. It was void(name, exception_ptr), which cannot
|
||||
// express the keep-running decision the node side needs, so it is now
|
||||
// NodeErrorHandler like StaticNetwork's.
|
||||
namespace {
|
||||
|
||||
static int throwing_stage(int x) {
|
||||
if (x == 42) throw std::runtime_error("boom");
|
||||
return x;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("network error handler receives the node's exception", "[network]") {
|
||||
auto src = kpn::make_node<throwing_stage>(kpn::in<"v">{}, kpn::out<"w">{}, 8);
|
||||
kpn::Channel<int> out(8);
|
||||
src.set_output_channel<0>(&out);
|
||||
|
||||
kpn::Network net;
|
||||
net.add("stage", src).build();
|
||||
|
||||
std::atomic<int> calls{0};
|
||||
std::string seen_name;
|
||||
std::string seen_what;
|
||||
std::mutex mx;
|
||||
|
||||
net.set_error_handler([&](std::string_view name, std::exception_ptr ep) {
|
||||
std::lock_guard lk(mx);
|
||||
seen_name = std::string(name);
|
||||
try { if (ep) std::rethrow_exception(ep); }
|
||||
catch (const std::exception& e) { seen_what = e.what(); }
|
||||
calls.fetch_add(1, std::memory_order_relaxed);
|
||||
return true; // handled: keep the node running
|
||||
});
|
||||
|
||||
net.set_watchdog_interval(std::chrono::hours(1)); // keep the report quiet
|
||||
net.start();
|
||||
src.input_channel<0>().push(42); // throws
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
src.input_channel<0>().push(7); // must still be running
|
||||
const int passed = out.pop();
|
||||
net.stop();
|
||||
|
||||
CHECK(calls.load(std::memory_order_relaxed) == 1);
|
||||
CHECK(seen_name == "stage");
|
||||
CHECK(seen_what == "boom");
|
||||
CHECK(passed == 7);
|
||||
}
|
||||
|
||||
// Regression: stopping a network must not wait for the watchdog's next tick.
|
||||
//
|
||||
// The watchdog looped on std::this_thread::sleep_for(watchdog_interval_), and
|
||||
// request_stop() cannot wake a sleeping thread — so stop_watchdog()'s join
|
||||
// blocked until the current sleep expired. Every teardown paid up to a full
|
||||
// interval, three seconds by default, and a caller who set a long one to keep
|
||||
// the periodic report quiet got a stop() that looked like a hang. That is how
|
||||
// this was found: the error-handler case above set an hour.
|
||||
TEST_CASE("stopping a network does not wait for the watchdog interval", "[network]") {
|
||||
auto node = kpn::make_node<increment>(kpn::in<"v">{}, kpn::out<"w">{}, 4);
|
||||
kpn::Channel<int> out(4);
|
||||
node.set_output_channel<0>(&out);
|
||||
|
||||
kpn::Network net;
|
||||
net.add("inc", node).build();
|
||||
net.set_watchdog_interval(std::chrono::hours(1));
|
||||
net.start();
|
||||
|
||||
// Let the watchdog actually reach its wait. Without this the test races it:
|
||||
// stop_watchdog() runs before the thread has entered the loop, the token is
|
||||
// already set when it does, and it exits without ever waiting — which passes
|
||||
// against the bug as well as the fix.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
net.stop();
|
||||
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - t0).count();
|
||||
|
||||
INFO("stop took " << ms << " ms");
|
||||
CHECK(ms < 2000);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <kpn/interrupt_node.hpp>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
using namespace kpn;
|
||||
@@ -239,3 +240,483 @@ TEST_CASE("interrupt node: trigger after stop is ignored", "[interrupt_node]") {
|
||||
REQUIRE(out_ch.approx_size() == 0);
|
||||
pool->stop();
|
||||
}
|
||||
|
||||
// ── Overflow callback ─────────────────────────────────────────────────────────
|
||||
|
||||
// NOTE: output overflow is no longer reachable on the data path. A node whose
|
||||
// output channel is full now PARKS — it keeps the value in a hidden one-slot
|
||||
// buffer, releases its scheduler worker, and is re-submitted when the consumer
|
||||
// frees a slot. The overflow callback survives for other producers (a direct
|
||||
// Channel::push by non-node code still throws), but a pool node cannot trigger
|
||||
// it, so these cases assert the stronger property instead: nothing is dropped.
|
||||
TEST_CASE("pool node parks instead of overflowing a full output", "[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();
|
||||
|
||||
// Parked, not overflowed: the value is still owned by the node.
|
||||
REQUIRE(overflow_count.load() == 0);
|
||||
// And it was never handed downstream, so nothing was lost or duplicated.
|
||||
REQUIRE(full_ch.size() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("parking is per node, not shared", "[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();
|
||||
|
||||
// Neither overflows now: A parks on its full output, B runs normally. The
|
||||
// point of the case is unchanged — one node's backpressure must not leak
|
||||
// into another's callbacks.
|
||||
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();
|
||||
|
||||
// Parking replaced overflow on the data path, so the network callback no
|
||||
// longer fires for a pool node's own output. See the note above.
|
||||
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();
|
||||
|
||||
// Both zero now: the node parks rather than overflowing. The case still
|
||||
// guards that the two callbacks are wired independently.
|
||||
REQUIRE(per_node.load() == 0);
|
||||
REQUIRE(network.load() == 0);
|
||||
}
|
||||
|
||||
// Regression: NodeSnapshot's fields must line up with what nodes initialise.
|
||||
//
|
||||
// The snapshot is an aggregate that every node type fills positionally, and
|
||||
// a8cfe73 appended queued/wake_pending/total_exec_ms to it in an order no call
|
||||
// site used: each node supplies total_exec_ms as the element straight after
|
||||
// queue_wait_ms, but the struct declared the two bools there. So the exec total
|
||||
// landed in `queued`, `queued` landed in `wake_pending`, and `wake_pending`
|
||||
// landed in total_exec_ms. The compiler said so (-Wnarrowing, bool to double,
|
||||
// once per node instantiation) and the build carried on.
|
||||
//
|
||||
// It matters more than a cosmetic mix-up: these three fields exist to diagnose a
|
||||
// wedge, and a wedged pipeline reported total_exec_ms as 0 or 1 and `queued` as
|
||||
// "did this node ever run". Reading them would have pointed at the wrong node.
|
||||
//
|
||||
// Asserted against ema_exec_ms because that field is independently computed and
|
||||
// was already correct: a true sum over several frames cannot be below the
|
||||
// exponentially-weighted average of the same samples.
|
||||
TEST_CASE("node snapshot fields line up with the values nodes supply",
|
||||
"[pool_node][diagnostics]") {
|
||||
auto pool = std::make_shared<ThreadPool>(1);
|
||||
pool->start();
|
||||
|
||||
auto node = make_pool_node<double_it>(pool, 64);
|
||||
Channel<int> out(64);
|
||||
node.set_output_channel<0>(&out);
|
||||
node.start();
|
||||
|
||||
for (int i = 0; i < 8; ++i) node.input_channel<0>().push(i);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
auto snap = node.node_snapshot("n", 1.0);
|
||||
node.stop();
|
||||
pool->stop();
|
||||
|
||||
INFO("frames=" << snap.frames_processed
|
||||
<< " ema=" << snap.ema_exec_ms
|
||||
<< " total=" << snap.total_exec_ms);
|
||||
REQUIRE(snap.frames_processed == 8);
|
||||
// The mis-ordered aggregate put wake_pending here, so this was 0.0 or 1.0.
|
||||
CHECK(snap.total_exec_ms >= snap.ema_exec_ms);
|
||||
// ...and the exec total here, which is non-zero, so `queued` read true for
|
||||
// any node that had ever run — including one asleep with nothing to do.
|
||||
CHECK_FALSE(snap.queued);
|
||||
CHECK_FALSE(snap.wake_pending);
|
||||
}
|
||||
|
||||
// Regression: a value parked twice must keep its payload.
|
||||
//
|
||||
// push_outputs ends with
|
||||
//
|
||||
// else pending_ = std::move(result);
|
||||
//
|
||||
// and the retry path calls it as push_outputs(std::move(*pending_), …), so on
|
||||
// that path `result` is the parked tuple itself. The assignment was therefore a
|
||||
// self-move-assignment. std::tuple's is elementwise, and libstdc++'s
|
||||
// std::vector does not guard against self-move: it swaps its data into a
|
||||
// temporary and leaves the vector empty. So the first park was clean (the
|
||||
// argument is a local temporary) and the second erased the payload.
|
||||
//
|
||||
// The value was still delivered, still in order, still counted — just empty.
|
||||
// Downstream cannot distinguish that from a frame on which the node genuinely
|
||||
// found nothing, which is why it never surfaced as an error: in
|
||||
// scene-actor-extraction it reads as "no faces in this frame" and the run
|
||||
// completes with a quietly wrong answer.
|
||||
//
|
||||
// Reaching it needs *two* outputs. With one, the only thing that resubmits a
|
||||
// parked node is that output's own space callback, which by definition fires
|
||||
// when there is room — so the retry always succeeds and never reassigns. With
|
||||
// two, output A draining resubmits the node while output B is still full: the
|
||||
// retry skips A (already delivered, tracked in pending_done_) and fails on B,
|
||||
// and that is the reassignment that eats B's payload.
|
||||
//
|
||||
// Driven through raw channels rather than consumer nodes so each step is
|
||||
// forced rather than raced: B is pre-filled and stays full for exactly as long
|
||||
// as the test wants it to.
|
||||
namespace {
|
||||
|
||||
struct TwoPayloads {
|
||||
static constexpr std::string_view label() { return "two_payloads"; }
|
||||
std::tuple<std::vector<int>, std::vector<int>> operator()() {
|
||||
return {std::vector<int>(4, 1), std::vector<int>(4, 2)};
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a twice-parked value keeps its payload", "[pool_node][backpressure]") {
|
||||
auto pool = std::make_shared<ThreadPool>(2);
|
||||
pool->start();
|
||||
|
||||
TwoPayloads fn;
|
||||
auto node = make_pool_node(fn, pool);
|
||||
|
||||
// Both capacity 1. A must be *full* for its pop to signal space at all —
|
||||
// Channel fires the space callback only on the full->not-full edge, so a
|
||||
// roomy A would never resubmit the node and the retry would never happen.
|
||||
Channel<std::vector<int>> out_a(1), out_b(1);
|
||||
node.set_output_channel<0>(&out_a);
|
||||
node.set_output_channel<1>(&out_b);
|
||||
|
||||
// B is full before the node ever runs, so the very first firing parks.
|
||||
out_b.push(std::vector<int>(4, 99));
|
||||
|
||||
node.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
|
||||
// Draining A resubmits the node while B is still full: this is the retry
|
||||
// that reassigned the tuple to itself.
|
||||
(void)out_a.pop();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
|
||||
// Now let B through and collect what the node had been holding for it.
|
||||
(void)out_b.pop(); // the pre-fill
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
std::vector<int> parked = out_b.pop(); // the value parked across two tries
|
||||
|
||||
node.stop();
|
||||
pool->stop();
|
||||
|
||||
INFO("parked payload size " << parked.size());
|
||||
CHECK(parked.size() == 4);
|
||||
if (parked.size() == 4) CHECK(parked[0] == 2);
|
||||
}
|
||||
|
||||
// Regression: a node woken with nothing to read must not stop itself.
|
||||
//
|
||||
// pop_one reported an empty channel the same way it reported a closed one, by
|
||||
// throwing ChannelClosedError, and fire_once treats that as "upstream is
|
||||
// finished" and calls self_stop(). self_stop disables the node's own inputs
|
||||
// *and* outputs, so one benign empty read does not merely skip a frame — it
|
||||
// kills the node and, through the disabled channels, the rest of the pipeline.
|
||||
//
|
||||
// A node genuinely does get woken with empty inputs: a space callback fires
|
||||
// when its output drains, which has nothing to do with input arrival. fire_once
|
||||
// guards against it by checking readiness before popping, and that guard is
|
||||
// what this test pins. pop_one now also distinguishes the two cases, so if the
|
||||
// guard is ever weakened the cost is a wasted firing rather than a dead node.
|
||||
//
|
||||
// The sequence below reaches the guard deliberately. The output is capacity 1
|
||||
// so that draining it signals space at all — Channel fires the space callback
|
||||
// only on the full->not-full edge — and by the final pop the input is long
|
||||
// since consumed, so the resulting firing has nothing to read.
|
||||
namespace {
|
||||
|
||||
struct CountingRelay {
|
||||
static constexpr std::string_view label() { return "counting_relay"; }
|
||||
std::atomic<int>* calls;
|
||||
int operator()(int v) { calls->fetch_add(1, std::memory_order_relaxed); return v; }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a node woken with empty inputs does not stop itself", "[pool_node]") {
|
||||
std::atomic<int> calls{0};
|
||||
std::atomic<int> closed{0};
|
||||
|
||||
auto pool = std::make_shared<ThreadPool>(2);
|
||||
pool->start();
|
||||
|
||||
CountingRelay fn{&calls};
|
||||
auto node = make_pool_node(fn, pool, 8);
|
||||
Channel<int> out(1);
|
||||
node.set_output_channel<0>(&out);
|
||||
node.set_closed_callback([&](auto) { closed.fetch_add(1, std::memory_order_relaxed); });
|
||||
|
||||
out.push(99); // output full before the node runs
|
||||
node.start();
|
||||
|
||||
node.input_channel<0>().push(1); // fires, cannot deliver, parks
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
|
||||
REQUIRE(out.pop() == 99); // space -> retry delivers the parked value
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
REQUIRE(out.pop() == 1); // space again -> fires with empty inputs
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
|
||||
// That firing had nothing to read. The node must still be alive.
|
||||
CHECK(closed.load(std::memory_order_relaxed) == 0);
|
||||
CHECK(node.running());
|
||||
|
||||
// And must still do its job when real input arrives.
|
||||
node.input_channel<0>().push(2);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
CHECK(out.pop() == 2);
|
||||
CHECK(calls.load(std::memory_order_relaxed) == 2);
|
||||
|
||||
node.stop();
|
||||
pool->stop();
|
||||
}
|
||||
|
||||
// Regression: stop() must not return while a firing is still running.
|
||||
//
|
||||
// stop() set the flag, disabled the inputs and returned, leaving an executing
|
||||
// fire_once touching input_channels_, stats_ and pending_ while the caller went
|
||||
// on to destroy them. The old comment was explicit that callers wanting the
|
||||
// guarantee should call scheduler_->drain() first — but ~PoolNode calls stop(),
|
||||
// and a destructor cannot ask its caller to have done that.
|
||||
//
|
||||
// A node with a private pool survived by accident: Node::stop() calls
|
||||
// pool->stop(), which joins the worker. A node sharing a pool, which
|
||||
// make_pool_node exists to create, had nothing joining it at all, so its own
|
||||
// destructor raced the firing.
|
||||
//
|
||||
// Asserted through an observable side effect rather than by trying to catch the
|
||||
// use-after-free: if stop() returns before the node function has finished, the
|
||||
// flag it sets on the way out is still false.
|
||||
namespace {
|
||||
|
||||
struct SlowFiring {
|
||||
static constexpr std::string_view label() { return "slow_firing"; }
|
||||
std::atomic<bool>* entered;
|
||||
std::atomic<bool>* finished;
|
||||
void operator()(int) {
|
||||
entered->store(true, std::memory_order_release);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
finished->store(true, std::memory_order_release);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("stop waits for a firing already in flight", "[pool_node]") {
|
||||
std::atomic<bool> entered{false}, finished{false};
|
||||
|
||||
auto pool = std::make_shared<ThreadPool>(2);
|
||||
pool->start();
|
||||
|
||||
SlowFiring fn{&entered, &finished};
|
||||
auto node = make_pool_node(fn, pool, 4);
|
||||
node.start();
|
||||
node.input_channel<0>().push(1);
|
||||
|
||||
// Stop only once the node is demonstrably inside its function.
|
||||
while (!entered.load(std::memory_order_acquire))
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
|
||||
node.stop();
|
||||
CHECK(finished.load(std::memory_order_acquire));
|
||||
|
||||
pool->stop();
|
||||
}
|
||||
|
||||
@@ -227,3 +227,116 @@ TEST_CASE("work stealing: tasks complete with more threads than initial queue ta
|
||||
REQUIRE(counter.load() == 4);
|
||||
pool.stop();
|
||||
}
|
||||
|
||||
// Regression: submitting to a stopped pool must be a no-op, not a segfault.
|
||||
//
|
||||
// stop() ends with queues_.clear(), and submit() went straight to
|
||||
// queues_[target] with no check — so a submission arriving after stop indexed
|
||||
// an empty vector.
|
||||
//
|
||||
// This is not a contrived teardown ordering; it happens on a normal path. A
|
||||
// node's space callback fires from whichever thread drained the channel, and
|
||||
// that thread belongs to the *consumer*. Stop the producer first — which a
|
||||
// sources-first shutdown does by design — and the consumer keeps draining its
|
||||
// backlog, firing the producer's space callback into a pool that has already
|
||||
// been torn down. Before this fix the static-network shutdown case crashed
|
||||
// about 12 runs in 20.
|
||||
//
|
||||
// Checking stopped_ without the lock would not be enough: the window between
|
||||
// reading the flag and indexing the vector is exactly where clear() runs.
|
||||
TEST_CASE("submitting to a stopped pool is refused, not fatal", "[scheduler]") {
|
||||
ThreadPool pool(2);
|
||||
pool.start();
|
||||
pool.stop();
|
||||
|
||||
std::atomic<int> ran{0};
|
||||
for (int i = 0; i < 10; ++i)
|
||||
pool.submit([&] { ran.fetch_add(1, std::memory_order_relaxed); });
|
||||
|
||||
CHECK(ran.load(std::memory_order_relaxed) == 0);
|
||||
CHECK(pool.rejected() == 10);
|
||||
}
|
||||
|
||||
TEST_CASE("submitting while the pool stops does not crash", "[scheduler]") {
|
||||
// The racing form of the case above: a producer thread submitting
|
||||
// continuously while stop() runs underneath it. Nothing is asserted about
|
||||
// how many tasks run — the point is that every submission either enqueues
|
||||
// or is refused, and none touches a destroyed queue.
|
||||
for (int rep = 0; rep < 20; ++rep) {
|
||||
ThreadPool pool(4);
|
||||
pool.start();
|
||||
|
||||
std::atomic<bool> go{false};
|
||||
std::atomic<int> ran{0};
|
||||
std::thread submitter([&] {
|
||||
while (!go.load(std::memory_order_acquire)) {}
|
||||
for (int i = 0; i < 2000; ++i)
|
||||
pool.submit([&] { ran.fetch_add(1, std::memory_order_relaxed); });
|
||||
});
|
||||
|
||||
go.store(true, std::memory_order_release);
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(200));
|
||||
pool.stop();
|
||||
submitter.join();
|
||||
|
||||
// Everything submitted was either executed or refused; nothing vanished
|
||||
// into a queue that no longer existed.
|
||||
CHECK(pool.rejected() + pool.snapshot("p").tasks_completed <= 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: idle workers must sleep while another worker is busy.
|
||||
//
|
||||
// The wait predicate was `stopped_ || total_ > 0`, and total_ counts queued
|
||||
// *plus executing*. So while any one task ran, every other worker's predicate
|
||||
// was true: wait() returned immediately and the worker spun through try_pop,
|
||||
// try_steal and back to wait at full speed — try_lock-ing every peer queue on
|
||||
// each pass. One slow task pinned every other core and contended the very
|
||||
// mutexes the working thread needed to make progress.
|
||||
//
|
||||
// That is the shape of this pipeline's load exactly: a handful of nodes whose
|
||||
// work is tens of milliseconds of ONNX inference. It was latent only because
|
||||
// each node currently owns a private single-thread pool, where there is no
|
||||
// idle peer to spin. Any use of a shared pool — which make_pool_node exists
|
||||
// for — hits it immediately.
|
||||
//
|
||||
// Measured as CPU time rather than wall time, because the bug does not make
|
||||
// anything slower to finish; it makes seven cores burn while one works. A
|
||||
// sleeping task consumes no CPU, so with workers correctly asleep the whole
|
||||
// pool should account for almost none.
|
||||
TEST_CASE("idle workers do not spin while one task runs", "[scheduler]") {
|
||||
auto cpu_ms = [] {
|
||||
struct timespec ts{};
|
||||
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ts);
|
||||
return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
|
||||
};
|
||||
|
||||
constexpr int kThreads = 8;
|
||||
constexpr int kWorkMs = 300;
|
||||
|
||||
ThreadPool pool(kThreads);
|
||||
pool.start();
|
||||
std::this_thread::sleep_for(20ms); // let workers reach the wait
|
||||
|
||||
const double before = cpu_ms();
|
||||
|
||||
// One long task plus a trivial one per remaining worker. The trivial ones
|
||||
// matter: a worker that has never been woken stays blocked in wait() and
|
||||
// never re-evaluates the predicate, so the spin only appears once a worker
|
||||
// *finishes* something and re-enters the loop while a peer is still busy.
|
||||
// Submitting only the long task does not reproduce it.
|
||||
pool.submit([&] { std::this_thread::sleep_for(std::chrono::milliseconds(kWorkMs)); });
|
||||
for (int i = 0; i < kThreads - 1; ++i) pool.submit([] {});
|
||||
|
||||
pool.drain();
|
||||
const double used = cpu_ms() - before;
|
||||
pool.stop();
|
||||
|
||||
// Measured on this tree: 1991 ms of CPU with the total_ predicate against
|
||||
// 0.4 ms with queued_, and 19205 voluntary context switches against 10 —
|
||||
// roughly (kThreads - 1) cores burned for the duration of one sleeping
|
||||
// task. The threshold sits far from both so the case is not sensitive to
|
||||
// how loaded the machine is.
|
||||
INFO("cpu " << used << " ms over " << kWorkMs << " ms of sleeping work");
|
||||
CHECK(used < kWorkMs);
|
||||
}
|
||||
|
||||
@@ -237,3 +237,51 @@ TEST_CASE("make_shared_resource constructs with forwarded args", "[shared_resour
|
||||
auto g = res.acquire();
|
||||
REQUIRE(*g == "hello");
|
||||
}
|
||||
|
||||
// Regression: a waiter must be releasable, or teardown waits on it forever.
|
||||
//
|
||||
// acquire() blocks on a condition variable whose predicate only becomes true
|
||||
// when release() hands over ownership. There was no timeout and no stop
|
||||
// condition, so a node parked there ignored teardown entirely: its worker never
|
||||
// returned, the pool's join never completed, and shutdown hung waiting for a
|
||||
// resource nobody was going to release — which is exactly the case when the
|
||||
// holder is being stopped too.
|
||||
//
|
||||
// close() turns that into an exception the node's existing error path already
|
||||
// handles, and networks now call it on registered resources before stopping any
|
||||
// node, for the same reason.
|
||||
TEST_CASE("closing a shared resource releases its waiters", "[shared_resource]") {
|
||||
SharedResource<int> res(42);
|
||||
|
||||
auto holder = res.acquire(); // resource is now held
|
||||
|
||||
std::atomic<bool> threw{false}, returned{false};
|
||||
std::thread waiter([&] {
|
||||
try {
|
||||
auto g = res.acquire(); // blocks: someone else holds it
|
||||
(void)g;
|
||||
} catch (const ResourceClosedError&) {
|
||||
threw.store(true, std::memory_order_release);
|
||||
}
|
||||
returned.store(true, std::memory_order_release);
|
||||
});
|
||||
|
||||
// Let it park, then tear down without ever releasing the holder.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
REQUIRE_FALSE(returned.load(std::memory_order_acquire));
|
||||
|
||||
res.close();
|
||||
waiter.join();
|
||||
|
||||
CHECK(threw.load(std::memory_order_acquire));
|
||||
}
|
||||
|
||||
TEST_CASE("acquiring a closed resource fails immediately", "[shared_resource]") {
|
||||
SharedResource<int> res(7);
|
||||
res.close();
|
||||
CHECK_THROWS_AS(res.acquire(), ResourceClosedError);
|
||||
|
||||
// Reusable across runs once reopened.
|
||||
res.reopen();
|
||||
CHECK_NOTHROW(res.acquire());
|
||||
}
|
||||
|
||||
@@ -271,3 +271,164 @@ TEST_CASE("static_network: fanout with labelled same-function consumers", "[stat
|
||||
REQUIRE(outB.pop() == 7);
|
||||
net.stop();
|
||||
}
|
||||
|
||||
// Regression: shutdown() must return even when a consumer stopped consuming.
|
||||
//
|
||||
// The drain step was an unbounded `while (anything anywhere is non-empty)` poll
|
||||
// over *every* channel in the graph. Two defects in one loop: it waited for the
|
||||
// whole network to be idle before stopping each successive layer rather than
|
||||
// just the node it had stopped — the dynamic Network's version even took a node
|
||||
// name and ignored it — and it had no deadline, so anything wedged downstream
|
||||
// turned a graceful shutdown into the hang it exists to avoid.
|
||||
//
|
||||
// It could also fail to terminate with nothing wedged at all. current_fill came
|
||||
// from a snapshot that loaded tail_ before head_; a concurrent pop between the
|
||||
// two reads yields a head_ past the sampled tail_, and the unsigned difference
|
||||
// wraps to ~2^64. Any poll for "is it empty yet" against that value runs
|
||||
// forever. Both indices only ever increase, so loading head_ first can at worst
|
||||
// under-report a push, which this loop tolerates and a wrap does not.
|
||||
//
|
||||
// Here the sink never takes anything, so its input cannot drain and the only
|
||||
// correct outcome is to give up and say so. The bound asserted is deliberately
|
||||
// loose: the point is that it terminates, not how fast.
|
||||
namespace {
|
||||
|
||||
struct DrainSource {
|
||||
static constexpr std::string_view label() { return "drain_source"; }
|
||||
int n{0};
|
||||
int operator()() {
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(100));
|
||||
return n++;
|
||||
}
|
||||
};
|
||||
|
||||
struct NeverConsumes {
|
||||
static constexpr std::string_view label() { return "never_consumes"; }
|
||||
std::atomic<bool>* wedged;
|
||||
void operator()(int) {
|
||||
// Blocks for the duration of the test: the input channel behind it
|
||||
// fills and stays full.
|
||||
while (!wedged->load(std::memory_order_acquire))
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("shutdown returns when a consumer has wedged", "[static_network][shutdown]") {
|
||||
std::atomic<bool> release{false};
|
||||
|
||||
DrainSource src_fn;
|
||||
NeverConsumes sink_fn{&release};
|
||||
|
||||
kpn::ObjectNode<DrainSource, kpn::in<>, kpn::out<"v">, "drain_source", 0> s(src_fn, 4);
|
||||
kpn::ObjectNode<NeverConsumes, kpn::in<"v">, kpn::out<>, "never_consumes", 0> k(sink_fn, 4);
|
||||
|
||||
auto net = kpn::make_network(kpn::edge(s.output<"v">(), k.input<"v">()));
|
||||
net.set_drain_timeout(std::chrono::milliseconds(100));
|
||||
net.start();
|
||||
|
||||
// Let the channel fill and the sink jam.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
// Unjam the sink well after the drain timeout should have expired. Stopping
|
||||
// a node joins its worker, so a sink blocked forever would hang the test in
|
||||
// stop() rather than in the drain loop this case is about.
|
||||
std::thread unjam([&] {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(800));
|
||||
release.store(true, std::memory_order_release);
|
||||
});
|
||||
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
net.shutdown();
|
||||
const auto elapsed = std::chrono::steady_clock::now() - t0;
|
||||
|
||||
unjam.join();
|
||||
|
||||
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
|
||||
INFO("shutdown took " << ms << " ms");
|
||||
CHECK(ms < 3000); // unbounded before; one 100 ms drain timeout after
|
||||
}
|
||||
|
||||
// Regression: node order must come from the topological sort, not from the
|
||||
// order the edges happened to be written in.
|
||||
//
|
||||
// make_network computes Topo for the cycle check and then dropped it, filling
|
||||
// the node vector in edge-declaration order — and named it user_nodes_topo_.
|
||||
// halt() stops in its reverse, and shutdown() walks it forwards stopping each
|
||||
// node and draining its outputs before moving to the next, which is a graceful
|
||||
// drain only if the order really is sources-first.
|
||||
//
|
||||
// Every network in this tree declares edges in pipeline order, so the two
|
||||
// coincided and nothing failed. This case declares them backwards, which is
|
||||
// legal and which make_network otherwise accepts silently.
|
||||
//
|
||||
// Asserted through shutdown() rather than by reading the order back, because
|
||||
// the order is private and the ordering is not the point — what it buys is.
|
||||
// A sources-first shutdown lets the values already in flight reach the sink;
|
||||
// stopping the sink first strands them, and the drain step then has nobody
|
||||
// left to take them.
|
||||
namespace {
|
||||
|
||||
struct OrderSource {
|
||||
static constexpr std::string_view label() { return "order_source"; }
|
||||
std::atomic<int>* made;
|
||||
int operator()() {
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(20));
|
||||
return made->fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
// Deliberately slower than the source, so a deep backlog builds up in its input
|
||||
// channel. That backlog is what a sources-first shutdown preserves and a
|
||||
// sink-first one throws away, and it needs to be big enough that the difference
|
||||
// cannot be mistaken for one value in flight.
|
||||
struct OrderRelay {
|
||||
static constexpr std::string_view label() { return "order_relay"; }
|
||||
int operator()(int v) {
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(300));
|
||||
return v;
|
||||
}
|
||||
};
|
||||
|
||||
struct OrderSink {
|
||||
static constexpr std::string_view label() { return "order_sink"; }
|
||||
std::atomic<int>* seen;
|
||||
void operator()(int) { seen->fetch_add(1, std::memory_order_relaxed); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("edges declared out of order still start and stop sources-first",
|
||||
"[static_network][shutdown]") {
|
||||
std::atomic<int> seen{0}, made{0};
|
||||
|
||||
OrderSource src_fn{&made};
|
||||
OrderRelay relay_fn;
|
||||
OrderSink sink_fn{&seen};
|
||||
|
||||
kpn::ObjectNode<OrderSource, kpn::in<>, kpn::out<"v">, "order_source", 0> s(src_fn, 8);
|
||||
kpn::ObjectNode<OrderRelay, kpn::in<"v">, kpn::out<"w">, "order_relay", 0> r(relay_fn, 64);
|
||||
kpn::ObjectNode<OrderSink, kpn::in<"w">, kpn::out<>, "order_sink", 0> k(sink_fn, 64);
|
||||
|
||||
// Sink edge first, source edge last — the reverse of pipeline order.
|
||||
auto net = kpn::make_network(
|
||||
kpn::edge(r.output<"w">(), k.input<"w">()),
|
||||
kpn::edge(s.output<"v">(), r.input<"v">())
|
||||
);
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
|
||||
const int before = seen.load(std::memory_order_relaxed);
|
||||
REQUIRE(before > 0); // the pipeline ran at all
|
||||
|
||||
net.shutdown();
|
||||
|
||||
// Sources stop first and each layer drains before the next stops, so the
|
||||
// backlog queued in front of the relay still reaches the sink. Stopping in
|
||||
// declaration order stops the relay first and discards all of it.
|
||||
const int after = seen.load(std::memory_order_relaxed);
|
||||
INFO("made " << made.load() << ", delivered " << before
|
||||
<< " before shutdown, " << after << " after");
|
||||
CHECK(after - before >= 20);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Regression: a node must never end up idle with a wake outstanding.
|
||||
//
|
||||
// 9c5ce5f established that invariant and implemented it as two independent
|
||||
// atomics — queued_ for "a firing is in flight", wake_pending_ for "a wake
|
||||
// arrived during one". Two variables cannot express it, because the release
|
||||
// side has to read and write both and a wake can land in between:
|
||||
//
|
||||
// producer (try_submit) worker (release_and_recheck)
|
||||
// ------------------------ ----------------------------
|
||||
// CAS reads queued_ == true, fails
|
||||
// queued_.store(false)
|
||||
// wake_pending_.exchange(false) -> false
|
||||
// wake_pending_.store(true)
|
||||
//
|
||||
// queued_ false, wake_pending_ true, nothing running and nothing scheduled.
|
||||
// Not a memory-ordering subtlety: the interleaving holds under seq_cst.
|
||||
//
|
||||
// LegacyGate below is that protocol verbatim, with a hook between the failed
|
||||
// CAS and the wake_pending_ store so the interleaving can be forced rather than
|
||||
// waited for. That makes the loss deterministic and the test non-flaky, and it
|
||||
// keeps the defect on record now that the code implementing it is gone.
|
||||
//
|
||||
// A note on what is NOT tested here, because it would be misleading to imply
|
||||
// otherwise: there is no black-box, node-level test that fails before this fix
|
||||
// and passes after. Every call site of release_and_recheck() happens to follow
|
||||
// it with a level re-check — on_input_ready(), or outputs_have_space() on the
|
||||
// parked path — which rediscovers the state a lost wake would have signalled.
|
||||
// That masking is a property of the call sites, not of the mechanism, and the
|
||||
// point of the fix is that a future early return that forgets the re-check no
|
||||
// longer reintroduces a hang. The value is structural, so the tests are
|
||||
// structural: the state machine is pinned by contract, and the defect it
|
||||
// replaces is pinned by demonstration.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <kpn/submit_gate.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <thread>
|
||||
|
||||
using namespace kpn;
|
||||
|
||||
namespace {
|
||||
|
||||
// The pre-fix protocol, with a seam at the point where the race lives.
|
||||
class LegacyGate {
|
||||
public:
|
||||
std::function<void()> before_recording_wake;
|
||||
|
||||
bool claim() noexcept {
|
||||
bool expected = false;
|
||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||
return true;
|
||||
if (before_recording_wake) before_recording_wake();
|
||||
wake_pending_.store(true, std::memory_order_release);
|
||||
return false;
|
||||
}
|
||||
bool release() noexcept {
|
||||
queued_.store(false, std::memory_order_release);
|
||||
if (wake_pending_.exchange(false, std::memory_order_acq_rel)) {
|
||||
bool expected = false;
|
||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool queued() const noexcept { return queued_.load(std::memory_order_relaxed); }
|
||||
bool wake_pending() const noexcept { return wake_pending_.load(std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
std::atomic<bool> queued_{false};
|
||||
std::atomic<bool> wake_pending_{false};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("the two-atomic gate loses a wake, deterministically", "[submit_gate]") {
|
||||
LegacyGate gate;
|
||||
bool resubmitted = true;
|
||||
|
||||
REQUIRE(gate.claim()); // a firing is now in flight
|
||||
|
||||
// Force the interleaving: the firing completes in the window between the
|
||||
// second wake's failed CAS and its record of that wake.
|
||||
gate.before_recording_wake = [&] { resubmitted = gate.release(); };
|
||||
|
||||
const bool submitted = gate.claim();
|
||||
|
||||
// The wake was neither submitted by the producer nor honoured by the
|
||||
// release. Nothing is scheduled, and nothing else will re-trigger it.
|
||||
CHECK_FALSE(submitted);
|
||||
CHECK_FALSE(resubmitted);
|
||||
CHECK_FALSE(gate.queued());
|
||||
CHECK(gate.wake_pending()); // recorded, and never to be consumed
|
||||
}
|
||||
|
||||
TEST_CASE("submit gate: a wake during a firing is honoured", "[submit_gate]") {
|
||||
SubmitGate gate;
|
||||
|
||||
REQUIRE(gate.claim()); // idle -> queued, caller submits
|
||||
REQUIRE(gate.queued());
|
||||
REQUIRE_FALSE(gate.wake_pending());
|
||||
|
||||
REQUIRE_FALSE(gate.claim()); // second wake is recorded, not submitted
|
||||
REQUIRE(gate.wake_pending());
|
||||
|
||||
REQUIRE(gate.release()); // and honoured when the firing ends
|
||||
// The gate stays claimed across the handover, so the node is never
|
||||
// momentarily idle while a submission for it is in flight. This is the
|
||||
// state the legacy gate could not represent.
|
||||
REQUIRE(gate.queued());
|
||||
REQUIRE_FALSE(gate.wake_pending());
|
||||
|
||||
REQUIRE_FALSE(gate.release()); // no further wake: now idle
|
||||
REQUIRE_FALSE(gate.queued());
|
||||
}
|
||||
|
||||
TEST_CASE("submit gate: repeated wakes collapse to one resubmission", "[submit_gate]") {
|
||||
// Collapsing is deliberate. A firing consumes one item and its caller then
|
||||
// re-checks the input level, so the gate only has to guarantee that at
|
||||
// least one more firing follows a wake, not one per wake.
|
||||
SubmitGate gate;
|
||||
REQUIRE(gate.claim());
|
||||
for (int i = 0; i < 10; ++i) REQUIRE_FALSE(gate.claim());
|
||||
REQUIRE(gate.release());
|
||||
REQUIRE_FALSE(gate.release());
|
||||
}
|
||||
|
||||
TEST_CASE("submit gate: force_idle drops a recorded wake", "[submit_gate]") {
|
||||
// Stop paths use this deliberately — honouring a wake there would resubmit
|
||||
// a node that has already been told to stop.
|
||||
SubmitGate gate;
|
||||
REQUIRE(gate.claim());
|
||||
REQUIRE_FALSE(gate.claim());
|
||||
REQUIRE(gate.wake_pending());
|
||||
|
||||
gate.force_idle();
|
||||
REQUIRE_FALSE(gate.queued());
|
||||
REQUIRE_FALSE(gate.wake_pending());
|
||||
REQUIRE(gate.claim()); // and the gate is reusable afterwards
|
||||
}
|
||||
|
||||
TEST_CASE("submit gate: concurrent claim and release stay consistent", "[submit_gate]") {
|
||||
// Not a lost-wake test — see the header note. This is a TSan target and a
|
||||
// check that the CAS loops always terminate and always leave the gate in a
|
||||
// reachable state: exactly one party may hold the claim at a time, so the
|
||||
// count of claims granted must equal the count of releases that ended idle.
|
||||
SubmitGate gate;
|
||||
std::atomic<long> granted{0}, ended_idle{0};
|
||||
std::atomic<bool> stop{false};
|
||||
|
||||
std::thread waker([&] {
|
||||
while (!stop.load(std::memory_order_relaxed))
|
||||
if (gate.claim()) granted.fetch_add(1, std::memory_order_relaxed);
|
||||
});
|
||||
std::thread worker([&] {
|
||||
while (!stop.load(std::memory_order_relaxed))
|
||||
if (gate.queued() && !gate.release())
|
||||
ended_idle.fetch_add(1, std::memory_order_relaxed);
|
||||
});
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
stop.store(true, std::memory_order_relaxed);
|
||||
waker.join();
|
||||
worker.join();
|
||||
|
||||
// Drain whatever claim is outstanding so the two counts can be compared.
|
||||
while (gate.queued())
|
||||
if (!gate.release()) ended_idle.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
INFO("granted " << granted.load() << " ended idle " << ended_idle.load());
|
||||
REQUIRE(granted.load() > 0);
|
||||
CHECK(granted.load() == ended_idle.load());
|
||||
}
|
||||
Reference in New Issue
Block a user