cpp-banns (1.0.0)
Installation
pip install --index-url --extra-index-url https://pypi.org/simple cpp-bannsAbout this package
C++ port of BANNs.py (libtorch backend) with optional CUDA support
cpp_banns — C++ port of BANNs.py (libtorch backend, CPU + CUDA)
This folder is a C++ port of ../Python Code/BANNs.py built on top of libtorch. It compiles into both
- a standalone C++ static library (
banns_core) with unit tests, and - a Python extension module (
cpp_banns) installed viapip install -e Cpp_code/, exposing exactly the same class surface as the Python original (NeuralNetwork,BANNs,Boosted_BANNs,Boosted_NNs).
The Python module supports a runtime device='cpu' | 'cuda' | 'auto' argument
and, when built with CUDA support, transparently runs the Gibbs sweep on the
GPU. The same source builds on a GPU-less server and on the developer's
RTX 5080 box — choose CPU-only at build time with -DBANNS_ENABLE_CUDA=OFF.
Prerequisites
All builds
-
CMake ≥ 3.20
-
A C++17 compiler (
g++ ≥ 11orclang++ ≥ 14) -
Python ≥ 3.10 with development headers (
sudo apt install python3-dev python3-venv python3-pip) -
A venv with
torch,numpy,pytest,pybind11,scikit-build-core:python3 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install numpy pytest pybind11 scikit-build-coreThen install the right
torchwheel for the target:# CPU-only target (server with no GPU) pip install torch --index-url https://download.pytorch.org/whl/cpu # CUDA target (dev machine with the RTX 5080, requires CUDA Toolkit 12.8+) pip install torch --index-url https://download.pytorch.org/whl/cu128
GPU builds additionally need
-
NVIDIA driver ≥ 555
-
CUDA Toolkit ≥ 12.8 — the RTX 5080 is Blackwell (sm_120). Earlier CUDA releases will silently compile but emit code that crashes at kernel launch on the 5080. Check with:
nvidia-smi nvcc --version
The default CUDA_ARCHITECTURES list is 75;120 — Turing (GTX 1660 SUPER)
and Blackwell (RTX 5080). Override with -DCMAKE_CUDA_ARCHITECTURES=... on
the CMake command line if you build on a different GPU.
Build the C++ side
Default AUTO device detection — CUDA if a toolkit is found, otherwise CPU:
cmake -S Cpp_code -B Cpp_code/build -DCMAKE_BUILD_TYPE=Release
cmake --build Cpp_code/build -j
ctest --test-dir Cpp_code/build --output-on-failure
Force CPU-only
cmake -S Cpp_code -B Cpp_code/build -DBANNS_ENABLE_CUDA=OFF
cmake --build Cpp_code/build -j
Force CUDA on (errors if toolkit missing)
cmake -S Cpp_code -B Cpp_code/build -DBANNS_ENABLE_CUDA=ON
cmake --build Cpp_code/build -j
You can have both build directories side-by-side: e.g. build_cpu/ and
build_cuda/. CMake doesn't care; only your pip install decides which
one the Python module loads.
Install the Python binding
From the repo root, with the venv active:
pip install -e Cpp_code/
scikit-build-core invokes CMake under the hood. The wheel inherits the
CUDA-ness of the build environment — if torch in your venv was the
cu128 wheel and nvcc is on PATH, the resulting cpp_banns module
will have CUDA enabled. Verify:
python -c "import cpp_banns; print('cuda?', cpp_banns.cuda_available())"
To force a CPU-only Python build even on a GPU machine:
CMAKE_ARGS="-DBANNS_ENABLE_CUDA=OFF" pip install -e Cpp_code/ --force-reinstall
Quickstart — use cpp_banns from Python
import numpy as np
import torch # required: must come BEFORE `import cpp_banns` so that
# libtorch_cpu.so is loaded into the process. cpp_banns
# is a pybind11 extension dynamically linked against
# libtorch; without this import you'll see
# `ImportError: libtorch_cpu.so: cannot open shared object file`
import cpp_banns
print("CUDA available:", cpp_banns.cuda_available())
# Synthetic regression problem
rng = np.random.default_rng(0)
X = rng.normal(size=(200, 5))
beta = np.array([1.0, -2.0, 0.5, 0.0, 1.0])
y = X @ beta + 0.3 * rng.normal(size=200)
X_test = rng.normal(size=(50, 5))
# Fit a BANNs ensemble (CPU-friendly settings)
model = cpp_banns.BANNs(
M=50,
hidden_layer_sizes=[8],
activation_functions=["tanh"],
seed=42,
device="auto", # 'cuda' if available, else 'cpu'
)
model.fit(
X, y,
n_sweeps=100, burn_in=50, n_sghmc_steps=5,
prior_var=1.0, X_test=X_test,
)
mean, lower, upper = model.predict(X_test,
quantiles=(0.05, 0.95),
predictive=True)
print("test RMSE vs zero-noise truth:",
np.sqrt(((mean - X_test @ beta) ** 2).mean()))
print("mean 90% CI width:", float(np.mean(upper - lower)))
Switching device at runtime
# Always works
model_cpu = cpp_banns.BANNs(M=100, device="cpu")
# Requires both: built with -DBANNS_ENABLE_CUDA=ON AND a visible GPU
model_cuda = cpp_banns.BANNs(M=100, device="cuda")
# Picks 'cuda' if available else 'cpu' (this is the default)
model_auto = cpp_banns.BANNs(M=100, device="auto")
Drop-in swap from the Python implementation
The class names and constructor / fit / predict signatures match
../Python Code/BANNs.py exactly. To switch a
driver script over, edit one line:
- from BANNs import BANNs, Boosted_BANNs, Boosted_NNs
+ from cpp_banns import BANNs, Boosted_BANNs, Boosted_NNs
Everything downstream — fit calls, predict calls, the (mean, lower, upper)
return tuple — stays identical.
Re-syncing from upstream UiO
The Python source under ../Python Code/ is mirrored from
the read-only upstream UiO repository (upstream git remote — see the repo
root). When UiO publishes updated Python code, run:
Cpp_code/scripts/sync_from_upstream.sh
The script:
-
git fetch upstream -
Compares each upstream
Python Code/*.pyagainst the snapshot recorded in .upstream_snapshot/ (a copy of the Python files as they were when the C++ was last hand-ported). -
If nothing changed: just rebuilds the C++ and runs the test suite.
-
If something changed: prints a colorized diff per file and, using the mapping table below, lists the C++ files that mirror each changed Python section. Exits with code 1 so automation can see manual work is pending.
-
After you've updated the C++ files, rerun with
--finalize:Cpp_code/scripts/sync_from_upstream.sh --finalizeThis merges the upstream Python into the working tree, refreshes the snapshot, rebuilds, retests, and revalidates.
Why isn't this fully automated?
Because there is no reliable Python-to-idiomatic-C++ translator for numerical numpy code. Tools like Pythran and Cython compile a restricted subset of Python; they don't produce the kind of readable, performant libtorch C++ this port is. So the script does the parts that can be automated (fetch, diff, point-at-files-to-edit, rebuild, retest) and leaves the actual C++ edits to a human.
Mapping table — which C++ files mirror which Python section
Python section in BANNs.py |
C++ files |
|---|---|
| Lines 1–110 (activations, loss, weight init, clipping) | include/banns/activation.hpp, src/activation.cpp, include/banns/loss.hpp, src/loss.cpp |
Lines 110–185 (forward, backprop) |
include/banns/neural_network.hpp, src/neural_network.cpp |
Lines 185–232 (adam, sghmc) |
include/banns/optimizers.hpp, src/optimizers.cpp |
Lines 234–276 (NeuralNetwork.fit/predict) |
src/neural_network.cpp |
BaseBANNs (standardize, feature subset, design matrix, posterior samplers) |
include/banns/base_banns.hpp, src/base_banns.cpp |
BANNs.fit |
include/banns/banns.hpp, src/banns.cpp |
Boosted_BANNs.fit |
include/banns/boosted_banns.hpp, src/boosted_banns.cpp |
Boosted_NNs.fit/predict |
include/banns/boosted_nns.hpp, src/boosted_nns.cpp |
Validation
End-to-end sanity check that the C++ port matches the Python reference within tolerance:
python Cpp_code/validation/run_validation.py
This runs the same synthetic problem through Python's BANNs.BANNs and
through cpp_banns.BANNs(device='cpu') (and device='cuda' if available)
across multiple seeds, prints a side-by-side metrics table, and exits 0
if every C++ metric agrees with Python within ±5% relative.
If a metric is outside ±5% — investigate, don't loosen the tolerance. The usual culprits are a sign error in a derivative or an off-by-one in the Gibbs sweep, and the unit tests don't catch either of those.
Project layout
Cpp_code/
├── CMakeLists.txt # build configuration, libtorch + Catch2 + pybind11
├── README.md # you are here
├── pyproject.toml # scikit-build-core packaging for `pip install`
├── .upstream_snapshot/ # baseline copies of upstream Python files
│ └── Python Code/
│ ├── BANNs.py
│ └── ...
├── include/banns/ # public C++ headers
│ ├── types.hpp # Tensor alias, tensor_opts(device)
│ ├── device.hpp # Device enum, parse + resolve
│ ├── activation.hpp # activation functions
│ ├── loss.hpp # MSE loss + derivative
│ ├── optimizers.hpp # Adam + SGHMC state and step functions
│ ├── neural_network.hpp # feed-forward NN class
│ ├── base_banns.hpp # common ensemble machinery
│ ├── banns.hpp # BANNs ensemble
│ ├── boosted_banns.hpp # Boosted BANNs ensemble
│ └── boosted_nns.hpp # deterministic boosted NN ensemble
├── src/ # implementations (one .cpp per .hpp)
├── bindings/
│ └── banns_module.cpp # pybind11 module → `cpp_banns`
├── tests/
│ ├── test_device.cpp # Catch2: device resolution
│ ├── test_activation.cpp # Catch2: activations + derivatives
│ ├── test_neural_network.cpp # Catch2: forward/backprop/Adam/SGHMC
│ ├── test_banns.cpp # Catch2: BANNs ensemble
│ ├── test_boosted.cpp # Catch2: boosted variants
│ └── test_python_binding.py # pytest: cpp_banns Python surface
├── scripts/
│ ├── README.md
│ ├── sync_from_upstream.sh # fetch upstream + diff + rebuild
│ └── export_onnx.py # train Python BANNs + export bayaneunets-*.onnx
├── docker/
│ ├── README.md
│ ├── Dockerfile.build # CPU build environment (libtorch-cpu + onnx)
│ ├── compose.build.yml # one-shot release pipeline service
│ └── build_and_export.sh # ctest + pytest + ONNX export + manifest
└── validation/
├── README.md
└── run_validation.py # Python vs C++ end-to-end comparison
The Gitea Actions workflow lives at .gitea/workflows/release.yml at the
repo root.
Release process — building cpp_banns + exporting bayaneunets ONNX
A release of this repo produces two artifacts: the CPU-only cpp_banns
Python wheel and a self-contained bayaneunets-vX.Y.Z.onnx model
trained on the UCI Airfoil Self-Noise dataset. Both land in
./artifacts/ on the host that ran the build.
Triggering a release on Gitea
The Gitea Actions workflow at
.gitea/workflows/release.yml fires on
git tag pushes matching v[0-9]+.[0-9]+.[0-9]+. Cut a release with:
git tag v1.0.0
git push origin v1.0.0
The workflow:
- Checks out the repo with full tag history (
fetch-depth: 0). - Derives
BAYANEUNETS_VERSIONfrom the tag (strips the leadingv). - Runs
docker compose -f Cpp_code/docker/compose.build.yml up --build --exit-code-from release— the compose file owns the build environment and the build steps. - Uploads
cpp_banns-*.whl,bayaneunets-*.onnx, andMANIFEST.txtas workflow artifacts (90-day retention).
Prerequisite: the gitea instance needs an Actions runner registered
(see gitea Actions docs).
The default act_runner Ubuntu image has Docker + Compose available.
Local dry run (no tag push)
The same compose file works locally, so you can verify the full pipeline before tagging:
mkdir -p artifacts
BAYANEUNETS_VERSION=1.0.0-rc1 \
docker compose -f Cpp_code/docker/compose.build.yml up \
--build --exit-code-from release
After ~5 minutes (depending on machine), ./artifacts/ contains:
| File | What it is |
|---|---|
cpp_banns-1.0.0.rc1-*.whl |
Pip-installable wheel of the C++ port |
bayaneunets-v1.0.0-rc1.onnx |
Trained BANNs as a 3-output ONNX graph |
MANIFEST.txt |
Versions, SHA-256s, git commit, build timestamp |
The compose file ${BAYANEUNETS_VERSION:?...} syntax refuses to run
unless the env var is set, enforcing "release builds only" even by hand.
Consuming bayaneunets-vX.Y.Z.onnx
The ONNX file is device-agnostic and self-contained — it ships
the feature scaler, the M weak networks, the posterior amplitudes, and
the y de-standardizer all inside the graph. Inputs are raw airfoil
features; outputs are (mean, lower, upper):
import numpy as np
import onnxruntime as ort
sess = ort.InferenceSession("bayaneunets-v1.0.0.onnx")
# X columns: [Frequency, Angle of attack, Chord length, Free-stream velocity,
# Suction-side displacement thickness], raw scale
X = np.array([[1000.0, 0.0, 0.3048, 71.3, 0.00266337]], dtype=np.float32)
mean, lower, upper = sess.run(None, {"X": X})
print(f"sound pressure ~ {mean[0]:.1f} dB (90% CI: {lower[0]:.1f}–{upper[0]:.1f})")
The graph uses only standard ONNX opset 17 ops (MatMul / Add / Tanh /
Sort / Gather), so any ONNX runtime works — onnxruntime, TensorRT,
OpenVINO, ML.NET, the browser-side onnxruntime-web, etc.
Versioning details
pyproject.tomlusessetuptools_scmto derive the wheel version from the git tag. Tagv1.2.3⇒ wheelcpp_banns-1.2.3-*.whl. Untagged builds get a0.0.0+devfallback.- The ONNX filename's version comes from the same
BAYANEUNETS_VERSIONenv var, set once at workflow entry and propagated through compose toexport_onnx.py --output. - Want to retrain the ONNX on a different dataset (one of the other UCI
datasets under
R Code/Datasets/)? Pass--dataset <path>toCpp_code/scripts/export_onnx.py. The CI build hard-codes airfoil; a follow-up enhancement is exposing this as a workflow input.
One-time setup: enable Gitea Packages publishing
By default, the workflow publishes everything to the workflow run's
Artifacts panel (always works, no setup needed). It can additionally
publish the wheel + onnx + examples directly to Gitea Packages, so
consumers get stable URLs (e.g. pip install --index-url https://gitea.debiansrv.ddns.net/api/packages/matmar/pypi/simple/ cpp_banns).
This needs a Personal Access Token because Gitea's auto-injected
workflow token (secrets.GITEA_TOKEN / secrets.GITHUB_TOKEN) is
read-only per the Gitea Actions docs.
One-time setup (admin):
- Generate a PAT at
https://gitea.debiansrv.ddns.net/user/settings/applications →
"Generate New Token". Give it any name (e.g.
banns-package-publish) and check just thewrite:packagescope. Copy the token — Gitea only shows it once. - Add it as a repo secret at
https://gitea.debiansrv.ddns.net/matmar/banns/settings/actions/secrets
→ "Add Secret". Name it
BANNS_PACKAGE_TOKEN(exact spelling — the workflow looks for this key), paste the token, save.
After the secret exists, every future tag push publishes to:
- PyPI registry:
https://gitea.debiansrv.ddns.net/-/packages/pypi/cpp_banns/X.Y.Z - Generic registry:
https://gitea.debiansrv.ddns.net/-/packages/generic/bayaneunets/vX.Y.Z
If the secret is absent, the workflow detects this and skips the publish steps with a friendly warning — the build itself still succeeds and the artifacts are still in the workflow run's Artifacts panel.
Fallback if no Actions runner
If the gitea instance has no Actions runner registered, run the local
dry-run above on the dev machine and upload the resulting artifacts/
contents to a Gitea Release manually via the web UI or tea release create.
Limitations
- Statistical, not bit-identical, equivalence with Python. numpy's PCG64 and libtorch's MT19937 produce different streams from the same seed, and libtorch's CPU vs CUDA RNGs also differ. Aggregate metrics agree within ±5%; individual samples do not match.
- Re-port is manual on upstream changes. The sync script detects what changed but does not auto-translate Python to C++. See Re-syncing from upstream UiO.
- CUDA build requires CUDA Toolkit ≥ 12.8 for the RTX 5080. Earlier toolkits compile but produce binaries that crash on Blackwell.
- CUDA-only on the GPU side. AMD ROCm / Intel SYCL are out of scope.
- Hand-coded backprop, not torch::autograd. The Python original uses hand-derived gradients; we preserve that for line-by-line auditability and to avoid autograd's tape-recording overhead on per-sweep small batches.