Skip to content

[ci] mex-build: build the MNN/OpenCL MATLAB MEX on Windows too #122

[ci] mex-build: build the MNN/OpenCL MATLAB MEX on Windows too

[ci] mex-build: build the MNN/OpenCL MATLAB MEX on Windows too #122

Workflow file for this run

name: ci
on:
push:
branches: [main, opencl]
# Tag pushes (v*) also run the workflow so release bundles get a clean,
# hash-less version: the Package steps key off GITHUB_REF_TYPE=tag and name
# artifacts siamize-<version>-<label> (vs <version>-<sha> on branch pushes).
# NOTE: GitHub reads this workflow from the *tagged commit*, so a tag only
# triggers if the tag points at a commit that already contains this filter.
tags: ['v*']
pull_request:
defaults:
run:
shell: bash
jobs:
cpp-build:
name: build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
binary: build/siamize
deps_check: |
echo "--- ldd ---"
ldd build/siamize
echo "--- direct (NEEDED) deps ---"
readelf -d build/siamize | grep NEEDED
- os: macos-14
binary: build/siamize
deps_check: |
echo "--- otool -L (linked dylibs) ---"
otool -L build/siamize
- os: windows-2022
# MSVC multi-config generator drops the binary under Release/.
binary: build/Release/siamize.exe
deps_check: |
ls -la build/Release/
steps:
- uses: actions/checkout@v4
- name: Install OpenMP (macOS)
if: runner.os == 'macOS'
run: |
brew install libomp
echo "OpenMP_ROOT=$(brew --prefix libomp)" >> "$GITHUB_ENV"
- name: Fetch C++ deps
run: scripts/fetch_deps.sh
- name: Configure
# -DSIAMIZE_STATIC_LINK=ON is the default but we pin it for clarity:
# statically links libstdc++/libgcc/libgomp (Linux), libomp (macOS),
# /MT static CRT (Windows). Only libonnxruntime remains dynamic.
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSIAMIZE_STATIC_LINK=ON
- name: Build
run: cmake --build build --config Release --parallel
- name: Inspect runtime deps
run: ${{ matrix.deps_check }}
- name: Smoke-run --help
run: ${{ matrix.binary }} --help
- name: Package
shell: bash
run: |
BASE=$(cat VERSION 2>/dev/null || echo "0.0.0")
# Tag push -> use tag verbatim (strip leading 'v' for the bundle).
# Branch push -> base version + short SHA so successive main builds
# are uniquely-named in the artifact list.
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VER="${GITHUB_REF_NAME#v}"
else
VER="${BASE}-${GITHUB_SHA:0:7}"
fi
case "${{ runner.os }}" in
Linux) PLAT=linux-x86_64 ;;
macOS) PLAT=macos ;;
Windows) PLAT=windows-x64 ;;
esac
BUNDLE="siamize-${VER}-${PLAT}-cpu"
scripts/package.sh cpu "$BUNDLE"
echo "BUNDLE=$BUNDLE" >> "$GITHUB_ENV"
- name: Upload bundle
uses: actions/upload-artifact@v4
with:
name: ${{ env.BUNDLE }}
path: dist/${{ env.BUNDLE }}
if-no-files-found: error
retention-days: 14
cpp-build-cuda:
# Builds siamize with the CUDA Execution Provider enabled, then runs
# single-fold inference. GitHub runners have no NVIDIA GPU, so the
# run exercises CUDA EP probe -> CPU fallback. The fp16 weight is
# fetched fresh each run from the public NeuroJSON URL that
# weights.cpp's default_weights_url() also uses.
name: build+run cuda (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
binary: build/siamize
- os: windows-2022
binary: build/Release/siamize.exe
steps:
- uses: actions/checkout@v4
- name: Fetch C++ deps (GPU ORT prebuilt)
env:
ORT_BUILD: gpu
run: scripts/fetch_deps.sh
- name: Fetch fold_0 weight
# Pulls the same dynamic-shape ONNX export that weights.cpp's
# default_weights_url() serves to end users, so CI exercises
# the canonical inference path (variable patch sizes via
# dynamic_axes={D,H,W}). ORT's CPU EP fits the default
# 256x256x192 patch at ~11-12 GB on the ~14 GB GHA cgroup with
# --no-arena, so the run step below keeps the default patch
# (8 tiles) -- no --lowmem / -P needed (unlike the MNN-CPU job,
# whose workspace allocation forces 192x192x128 / 27 tiles).
run: |
URL='https://neurojson.org/io/stat.cgi?action=get&db=siam_v03&doc=dynshape&size=95360591&file=fold_0_fp16.onnx.gz'
mkdir -p models
curl -fsSL "$URL" -o models/fold_0_fp16.onnx.gz
gunzip -f models/fold_0_fp16.onnx.gz
ls -la models/
- name: Configure (CUDA EP)
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DSIAMIZE_STATIC_LINK=ON \
-DSIAMIZE_GPU=cuda
- name: Build
run: cmake --build build --config Release --parallel
- name: Run single-fold inference (expect CPU fallback on GPU-less runner)
# GitHub runners have no NVIDIA GPU, so siamize falls back to
# CPU. Dynamic-shape ONNX + default 256x256x192 + --no-arena
# peaks ~11-12 GB on the 14 GB GHA cgroup (the historical
# OOM was a stale arena-ignore bug, fixed in 6d052ed). 8
# tiles, end-to-end ~500-550 s.
timeout-minutes: 30
run: |
${{ matrix.binary }} \
--no-arena \
-i tests/sub-01_T1w.nii.gz \
-o pred_ci.nii.gz \
-M models/fold_0_fp16.onnx \
-c auto 2>&1 | tee siamize.log
- name: Verify CPU fallback happened
run: |
if grep -Eq "\[cuda\][[:space:]]+unavailable" siamize.log; then
echo "OK: CUDA EP correctly fell back to CPU."
else
echo "FAIL: expected '[cuda] unavailable' fallback message; got:"
cat siamize.log
exit 1
fi
- name: Verify output produced
run: |
test -s pred_ci.nii.gz || { echo "FAIL: empty/missing output"; exit 1; }
ls -lh pred_ci.nii.gz
- name: Package
shell: bash
run: |
BASE=$(cat VERSION 2>/dev/null || echo "0.0.0")
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VER="${GITHUB_REF_NAME#v}"
else
VER="${BASE}-${GITHUB_SHA:0:7}"
fi
case "${{ runner.os }}" in
Linux) PLAT=linux-x86_64 ;;
Windows) PLAT=windows-x64 ;;
esac
BUNDLE="siamize-${VER}-${PLAT}-cuda"
scripts/package.sh cuda "$BUNDLE"
echo "BUNDLE=$BUNDLE" >> "$GITHUB_ENV"
- name: Upload bundle
uses: actions/upload-artifact@v4
with:
name: ${{ env.BUNDLE }}
path: dist/${{ env.BUNDLE }}
if-no-files-found: error
retention-days: 14
mex-build:
# Build siamize's MATLAB/Octave MEX wrapper and verify it loads. Heavy
# full-inference test is left to cpp-build-cuda; this job's job is just
# to catch MEX build regressions on each supported host.
name: mex (${{ matrix.label }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- label: linux-octave
os: ubuntu-22.04
kind: octave
cmake_flag: -DSIAMIZE_BUILD_OCTAVE_MEX=ON
mex_file: matlab/siamex.mex
mnn_mex: true
mnn_mex_target: siamize_oct
- label: linux-matlab
os: ubuntu-22.04
kind: matlab
cmake_flag: -DSIAMIZE_BUILD_MATLAB_MEX=ON
mex_file: matlab/siamex.mexa64
mnn_mex: true
mnn_mex_target: siamize_mex
- label: windows-matlab
os: windows-2022
kind: matlab
cmake_flag: -DSIAMIZE_BUILD_MATLAB_MEX=ON
mex_file: matlab/siamex.mexw64
mnn_mex: true
mnn_mex_target: siamize_mex
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Octave (Linux)
if: matrix.kind == 'octave' && runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends octave liboctave-dev
- name: Set up MATLAB
if: matrix.kind == 'matlab'
uses: matlab-actions/setup-matlab@v2
with:
release: R2024a
- name: Install GCC 10 (Linux MATLAB)
# MATLAB R2024a on Linux bundles a libstdc++.so.6 that supports
# GLIBCXX <=3.4.28 (GCC 9 / 10). The ubuntu-22.04 runner ships
# GCC 11 as the default, whose libstdc++ headers reference
# GLIBCXX_3.4.29 symbols and produce a MEX MATLAB can't load.
# GCC 10 is MathWorks' officially supported MEX compiler for
# R2024a on Linux.
if: matrix.kind == 'matlab' && runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends g++-10 gcc-10
- name: Fetch C++ deps (CPU ORT)
run: scripts/fetch_deps.sh
- name: Configure
env:
# Pin to GCC 10 for the MATLAB-on-Linux leg so the MEX only
# references GLIBCXX symbols MATLAB's bundled libstdc++ has.
CC: ${{ (matrix.kind == 'matlab' && runner.os == 'Linux') && 'gcc-10' || '' }}
CXX: ${{ (matrix.kind == 'matlab' && runner.os == 'Linux') && 'g++-10' || '' }}
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release ${{ matrix.cmake_flag }}
- name: Build
run: cmake --build build --config Release --parallel
- name: Verify MEX exists
run: |
ls -la "${{ matrix.mex_file }}"
test -s "${{ matrix.mex_file }}"
- name: Smoke-load MEX (Octave)
if: matrix.kind == 'octave' && runner.os == 'Linux'
run: |
# Call siamex (the low-level MEX) with zero args to trigger the
# C++ "Usage:" error. Proves the .mex loaded + reached mexFunction.
octave-cli --no-gui --eval "addpath('matlab'); \
try; siamex(); catch err; \
if isempty(strfind(err.message, 'Usage:')); error('unexpected: %s', err.message); end; \
fprintf('MEX loaded and reached argument check\\n'); \
end" 2>&1 | tee mex.log
grep -q "MEX loaded" mex.log
- name: Run siamize.m unit tests (Octave)
if: matrix.kind == 'octave' && runner.os == 'Linux'
run: |
# Pure-MATLAB dispatcher tests. Stubs siamex internally so it
# doesn't depend on ORT or weight files; needs the jsonlab
# submodule (pulled via submodules: recursive above).
octave-cli --no-gui --eval "cd matlab/tests; run_tests('--exit')"
- name: Smoke-load MEX (MATLAB)
if: matrix.kind == 'matlab'
uses: matlab-actions/run-command@v2
with:
command: |
addpath(fullfile(pwd, 'matlab'));
% cd into matlab/ on Windows so the MEX picks up the
% onnxruntime.dll the POST_BUILD step copied there via the
% EXE search rule. On Linux the rpath handles it.
if ispc; cd('matlab'); end
try
siamex();
error('siamex() with no args should have thrown');
catch err
if isempty(strfind(err.message, 'Usage:'))
error('unexpected: %s', err.message);
end
fprintf('MEX loaded and reached argument check\n');
end
- name: Run siamize.m unit tests (MATLAB)
if: matrix.kind == 'matlab'
uses: matlab-actions/run-command@v2
with:
command: |
cd(fullfile('matlab', 'tests'));
[~, nfail] = run_tests();
assert(nfail == 0, 'siamize.m unit tests failed (%d)', nfail);
- name: Package
shell: bash
run: |
BASE=$(cat VERSION 2>/dev/null || echo "0.0.0")
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VER="${GITHUB_REF_NAME#v}"
else
VER="${BASE}-${GITHUB_SHA:0:7}"
fi
BUNDLE="siamex-${VER}-${{ matrix.label }}"
scripts/package.sh mex "$BUNDLE"
echo "BUNDLE=$BUNDLE" >> "$GITHUB_ENV"
- name: Upload bundle
uses: actions/upload-artifact@v4
with:
name: ${{ env.BUNDLE }}
path: dist/${{ env.BUNDLE }}
if-no-files-found: error
retention-days: 14
# --- MNN/OpenCL MEX (vendor-neutral GPU backend) --------------------
# Build the same MEX wrapper against the MNN backend and upload it as a
# separate artifact. It writes to the same matlab/siamex.mex* path as the
# ORT MEX above, so this runs AFTER the ORT bundle is packaged + uploaded.
# Linux legs only (matrix.mnn_mex); the OpenCL ICD is dlopen'd at runtime
# so the smoke-load needs no GPU. Static libMNN.a is embedded into the
# MEX (no libMNN.so to ship), matching `make openclmex` / `opencloct`.
- name: Cache MNN stage (libMNN.a + headers)
if: matrix.mnn_mex
id: mnn-mex-cache
uses: actions/cache@v4
with:
path: third_party/mnn
# Per-leg key: the MATLAB leg builds MNN with GCC 10 (to match
# MATLAB's libstdc++), the Octave leg with the runner default, so
# their staged libMNN.a are not interchangeable.
key: mnn-mex-${{ matrix.label }}-${{ hashFiles('scripts/fetch_mnn.sh') }}-v3.5-opencl-conv3d-r6
- name: Fetch + build patched MNN (static)
# First run: ~15-20 min compile; later runs hit the cache above.
# SIAMIZE_GLIBCXX_COMPAT=1 pins condition_variable::wait@3.4.11 in
# libMNN so the (dynamic-libstdc++) MEX loads in MATLAB's older
# libstdc++. Safe here because the MEX never uses -static-libstdc++;
# the CLI/docker MNN builds leave it unset (see fetch_mnn.sh).
if: matrix.mnn_mex && steps.mnn-mex-cache.outputs.cache-hit != 'true'
env:
MNN_STATIC: "1"
SIAMIZE_GLIBCXX_COMPAT: "1"
# The MEX uses MSVC's dynamic CRT (/MD); build MNN /MD to match
# (MNN_WIN_MT=0). Ignored off Windows. The CLI opencl build leaves
# this default (=1 -> /MT) to match siamize.exe's static CRT.
MNN_WIN_MT: "0"
CC: ${{ (matrix.kind == 'matlab' && runner.os == 'Linux') && 'gcc-10' || '' }}
CXX: ${{ (matrix.kind == 'matlab' && runner.os == 'Linux') && 'g++-10' || '' }}
run: scripts/fetch_mnn.sh
- name: Build MNN/OpenCL MEX
# Build ONLY the MEX target -- NOT the `siamize` CLI. The CLI links
# -static-libstdc++, which cannot resolve the @3.4.11 versioned
# reference in the compat-built libMNN; the MEX uses dynamic libstdc++
# and links it fine.
if: matrix.mnn_mex
env:
CC: ${{ (matrix.kind == 'matlab' && runner.os == 'Linux') && 'gcc-10' || '' }}
CXX: ${{ (matrix.kind == 'matlab' && runner.os == 'Linux') && 'g++-10' || '' }}
run: |
cmake -S . -B build-mnn -DCMAKE_BUILD_TYPE=Release \
-DSIAMIZE_BACKEND=mnn ${{ matrix.cmake_flag }}
cmake --build build-mnn --config Release --parallel \
--target ${{ matrix.mnn_mex_target }}
- name: Verify MNN MEX exists
if: matrix.mnn_mex
run: |
ls -la "${{ matrix.mex_file }}"
test -s "${{ matrix.mex_file }}"
# Diagnostic: the highest GLIBCXX symbol version the MEX requires.
# With the GCC 10 toolchain pin (fetch_mnn + the Build step) this
# must stay <= 3.4.28 so the MEX loads in stock MATLAB. A higher
# value here means the GCC 10 pin regressed and the MATLAB smoke-load
# below will fail -- fix the toolchain, not with a runtime preload.
echo "--- MNN MEX max GLIBCXX requirement (want <= 3.4.28 for MATLAB) ---"
objdump -T "${{ matrix.mex_file }}" 2>/dev/null \
| grep -oE "GLIBCXX_[0-9.]+" | sort -V | tail -3 || true
- name: Smoke-load MNN MEX (Octave)
if: matrix.mnn_mex && matrix.kind == 'octave' && runner.os == 'Linux'
run: |
octave-cli --no-gui --eval "addpath('matlab'); \
try; siamex(); catch err; \
if isempty(strfind(err.message, 'Usage:')); error('unexpected: %s', err.message); end; \
fprintf('MNN MEX loaded and reached argument check\\n'); \
end" 2>&1 | tee mex-mnn.log
grep -q "MNN MEX loaded" mex-mnn.log
- name: Smoke-load MNN MEX (MATLAB)
# No LD_PRELOAD: libMNN is built with GCC 10 (see fetch_mnn's compiler
# pin + the gcc-10 CC/CXX on the Build step), so the MEX stays within
# MATLAB's bundled libstdc++ and must load as-is -- exactly like the
# ORT MATLAB MEX. If this step regresses on a GLIBCXX symbol, the
# diagnostic above shows the floor and the fix is the toolchain, not a
# preload that would only mask a non-portable artifact.
if: matrix.mnn_mex && matrix.kind == 'matlab'
uses: matlab-actions/run-command@v2
with:
command: |
addpath(fullfile(pwd, 'matlab'));
try
siamex();
error('siamex() with no args should have thrown');
catch err
if isempty(strfind(err.message, 'Usage:'))
error('unexpected: %s', err.message);
end
fprintf('MNN MEX loaded and reached argument check\n');
end
- name: Package MNN/OpenCL MEX
if: matrix.mnn_mex
shell: bash
run: |
BASE=$(cat VERSION 2>/dev/null || echo "0.0.0")
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VER="${GITHUB_REF_NAME#v}"
else
VER="${BASE}-${GITHUB_SHA:0:7}"
fi
MNN_BUNDLE="siamex-opencl-${VER}-${{ matrix.label }}"
scripts/package.sh openclmex "$MNN_BUNDLE"
echo "MNN_BUNDLE=$MNN_BUNDLE" >> "$GITHUB_ENV"
- name: Upload MNN/OpenCL MEX bundle
if: matrix.mnn_mex
uses: actions/upload-artifact@v4
with:
name: ${{ env.MNN_BUNDLE }}
path: dist/${{ env.MNN_BUNDLE }}
if-no-files-found: error
retention-days: 14
cpp-build-coreml:
# Builds siamize with the CoreML Execution Provider enabled and
# actually runs end-to-end inference on a real Apple Silicon runner
# (M-class CPU + Metal GPU + Neural Engine). Unlike cpp-build-cuda
# where GHA has no NVIDIA GPU, GHA's macos-14 runner DOES expose
# the full Apple Silicon stack to CoreML, so this is the "GPU
# smoke" leg for macOS.
name: build+run coreml (macos-14)
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install OpenMP
run: |
brew install libomp
echo "OpenMP_ROOT=$(brew --prefix libomp)" >> "$GITHUB_ENV"
- name: Fetch C++ deps (CPU ORT -- macOS dylib already includes CoreML EP)
run: scripts/fetch_deps.sh
- name: Configure (CoreML EP)
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DSIAMIZE_STATIC_LINK=ON \
-DSIAMIZE_GPU=coreml
- name: Build
run: cmake --build build --config Release --parallel
- name: Smoke-run --help (always)
# Cheap sanity check on every push: the binary launches and the
# CoreML-EP-aware help text is intact. Full inference is gated
# behind the tag-push branch below since CPU-only Core ML on the
# 7 GB free runner takes ~7-10 min and adds no regression value
# to normal CI.
run: build/siamize --help | head -20
# The remaining steps (fetch weight, run, verify, package) execute
# only on tag pushes -- that's where we actually want to validate
# the macOS-arm64-coreml release bundle end-to-end. Pull requests
# and main-branch pushes stop at "Build" above.
# DISABLED end-to-end CoreML inference: on the ~7 GB macos-14 runner the
# GPU/ANE path OOMs, so it falls back to MLComputeUnits=CPUOnly, which is
# too slow (>30 min) and timed out the tag CI. The Build + `--help` steps
# above still validate the CoreML build, and Package/Upload below still
# ship the macOS-arm64-coreml bundle -- only the inference smoke test is
# off. Re-enable (flip `if: false` back to `github.ref_type == 'tag'`)
# when a larger macOS runner can actually exercise the GPU/ANE path.
- name: Fetch fold_0 weight (CoreML variant; InstanceNorm rewritten)
if: false
# SIAM v0.3's encoder/decoder uses 3D InstanceNorm, which Apple's
# mlcompilerd doesn't accept in MLProgram format (only rank 3-4).
# We host a rewritten variant on the NeuroJSON server under
# doc=coreml where rank-5 InstanceNorm has been replaced with
# Reshape -> InstanceNorm-rank-3 -> Reshape (see
# tools/onnx_export/rewrite_for_coreml.py). siamize's weight
# resolver auto-picks this variant when -c coreml is active.
run: |
URL='https://neurojson.org/io/stat.cgi?action=get&db=siam_v03&doc=coreml&file=fold_0_fp16.onnx.gz'
mkdir -p models
curl -fsSL "$URL" -o models/fold_0_fp16.onnx.gz
gunzip -f models/fold_0_fp16.onnx.gz
ls -la models/
- name: Run single-fold inference on Apple Silicon
if: false
# macos-14 free-tier runner has ~7 GB RAM, which forces
# auto-lowmem to MLComputeUnits=CPUOnly (the only target whose
# mlcompilerd peak fits the budget; ALL and even cpugpu OOM at
# exit 137). CPU-only Core ML completes in ~7-10 min wall.
# Local benchmarks on hosts with >=14 GB free can override with
# --coreml-units all to exercise the Metal GPU + ANE paths.
timeout-minutes: 30
run: |
build/siamize \
-i tests/sub-01_T1w.nii.gz \
-o pred_ci.nii.gz \
-M models/fold_0_fp16.onnx \
-c auto 2>&1 | tee siamize.log
- name: Verify CoreML EP registered
if: false
run: |
if ! grep -Eq "\[coreml\][[:space:]]+enabled" siamize.log; then
echo "FAIL: expected '[coreml] enabled' line; got:"
cat siamize.log
exit 1
fi
echo "OK: CoreML EP registered."
- name: Verify output produced
if: false
run: |
test -s pred_ci.nii.gz || { echo "FAIL: empty/missing output"; exit 1; }
ls -lh pred_ci.nii.gz
- name: Package
if: github.ref_type == 'tag'
shell: bash
run: |
VER="${GITHUB_REF_NAME#v}"
BUNDLE="siamize-${VER}-macos-arm64-coreml"
# package.sh's 'cuda' / 'tensorrt' modes pull GPU-specific libs;
# the macOS CoreML bundle is just the CPU package (CoreML EP is
# statically baked into libonnxruntime.dylib on macOS).
scripts/package.sh cpu "$BUNDLE"
echo "BUNDLE=$BUNDLE" >> "$GITHUB_ENV"
- name: Upload bundle
if: github.ref_type == 'tag'
uses: actions/upload-artifact@v4
with:
name: ${{ env.BUNDLE }}
path: dist/${{ env.BUNDLE }}
if-no-files-found: error
retention-days: 14
cpp-build-opencl:
# Builds siamize with SIAMIZE_BACKEND=mnn (single static libMNN.a)
# and runs single-fold inference end-to-end on the GHA runner.
# The MNN-CPU forward type works without a GPU, so this job
# genuinely verifies the inference produces a non-empty output on
# every push -- not just a fallback to a different backend the
# way cpp-build-cuda has to. The .mnn weight (int8-quantized,
# ~35 MB gzipped) auto-downloads from doc=mnn_i8a via siamize's
# built-in resolver, exercising the canonical user path.
name: build+run opencl (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
binary: build/siamize
- os: macos-14
binary: build/siamize
- os: windows-2022
# MSVC multi-config generator drops the binary under Release/.
binary: build/Release/siamize.exe
steps:
- uses: actions/checkout@v4
- name: Install OpenMP (macOS)
if: runner.os == 'macOS'
run: |
brew install libomp
echo "OpenMP_ROOT=$(brew --prefix libomp)" >> "$GITHUB_ENV"
- name: Cache MNN stage (libMNN.a + headers)
id: mnn-cache
uses: actions/cache@v4
with:
# We only need the staged artifacts; the heavy intermediate
# build tree under third_party/mnn-build/ is not cached
# (caching the 1 GB CMakeFiles tree saved less time than
# the cache restore/save itself in benchmarking).
path: third_party/mnn
# Key off the script that drives the build + the ref it
# downloads. Bump either to invalidate the cache.
key: mnn-${{ runner.os }}-static-${{ hashFiles('scripts/fetch_mnn.sh') }}-v3.5-opencl-conv3d-r6
- name: Fetch + build patched MNN (static)
# First run: 15-20 min compile. Subsequent runs hit the cache
# above and skip this step entirely. fetch_mnn.sh itself is
# idempotent (FORCE=1 to bust), but we don't need it: the
# actions/cache step decides whether the work is needed.
if: steps.mnn-cache.outputs.cache-hit != 'true'
env:
MNN_STATIC: "1"
run: scripts/fetch_mnn.sh
- name: Configure (MNN backend)
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DSIAMIZE_STATIC_LINK=ON \
-DSIAMIZE_BACKEND=mnn
- name: Build
run: cmake --build build --config Release --parallel
- name: Inspect runtime deps (Linux)
if: runner.os == 'Linux'
run: |
echo "--- ldd ---"
ldd build/siamize
echo "--- siamize binary size ---"
ls -lh build/siamize
stat -c "%s" build/siamize
- name: Inspect runtime deps (macOS)
if: runner.os == 'macOS'
run: |
echo "--- otool -L (linked dylibs) ---"
otool -L build/siamize
ls -lh build/siamize
- name: Inspect runtime deps (Windows)
if: runner.os == 'Windows'
run: ls -la build/Release/
- name: Smoke-run --help
run: ${{ matrix.binary }} --help
# The end-to-end MNN-CPU inference + Dice check below run on Linux
# only: they exercise the inference path once per push and rely on
# Linux tooling (ldd/lscpu/free, the -P workspace tuning calibrated to
# the Linux runner's cgroup). The macOS / Windows legs are build +
# --help + package, mirroring cpp-build, so we still ship and smoke the
# MNN/OpenCL binaries on those platforms.
- name: Runner CPU / memory profile (diagnostic)
if: runner.os == 'Linux'
# Helps triage MNN crashes that depend on the runner's
# /proc/cpuinfo, /sys/devices/system/cpu/cpufreq, or memory
# layout -- MNN's CPU runtime probes all three at session
# creation time.
run: |
echo "--- CPU ---"
lscpu | head -25
echo "--- memory ---"
free -h
echo "--- /sys/devices/system/cpu/cpufreq ---"
ls /sys/devices/system/cpu/cpufreq/ 2>&1 | head -10 || true
echo "--- ulimit ---"
ulimit -a
- name: Run single-fold MNN-CPU inference
# int8-quantized .mnn weight auto-downloads from doc=mnn_i8a
# (~35 MB gzipped -> 143 MB on disk). MNN's CPU forward type
# is the safe choice on GHA -- no OpenCL ICD to depend on.
# Threads: auto (no -t) -> min(hardware_concurrency, 8); on the
# 4-vCPU runner that is 4. The old -t 2 worked around a CPURaster
# MT segfault that the native-Conv3D path (this -M 0 / n3d model)
# no longer hits. If a runner segfaults at >2 threads, restore -t 2.
#
# -P 192x192x128 (instead of the default 256x256x192) so
# MNN's resizeSession workspace allocation stays under the
# ~14 GB GHA cgroup. The default patch pre-allocates the
# full intermediate-tensor pyramid at ctor time, which on
# this runner segfaults inside MNN before the engine prints
# its first log line. The smaller patch is the same one
# siamize's auto-lowmem heuristic picks on RAM-tight hosts;
# Dice stays in spec across patch sizes.
if: runner.os == 'Linux'
timeout-minutes: 30
run: |
${{ matrix.binary }} \
-i tests/sub-01_T1w.nii.gz \
-o pred_ci.nii.gz \
-M 0 \
-c cpu \
-P 192x192x128 2>&1 | tee siamize.log
- name: Verify MNN backend was used
if: runner.os == 'Linux'
run: |
if grep -Eq "backend mnn" siamize.log; then
echo "OK: MNN backend engaged."
else
echo "FAIL: expected 'backend mnn' marker; got:"
cat siamize.log
exit 1
fi
- name: Verify output produced
if: runner.os == 'Linux'
run: |
test -s pred_ci.nii.gz || { echo "FAIL: empty/missing output"; exit 1; }
ls -lh pred_ci.nii.gz
- name: Compare to bundled reference (mean foreground Dice)
if: runner.os == 'Linux'
# Bundled reference (tests/pred_ref_allfolds.nii.gz) is the
# 5-fold ORT ensemble; we're running single-fold MNN-int8 here,
# so the comparison is "do we get the same general structure"
# rather than an equality check. Use a generous floor: any
# mean-fg Dice < 0.90 on this T1 means something is badly
# broken (silent garbage logits, wrong weight bundle, etc).
run: |
python3 -m pip install --quiet nibabel numpy
python3 - <<'PY'
import nibabel as nib, numpy as np
new = nib.load('pred_ci.nii.gz').get_fdata().astype(np.int32)
ref = nib.load('tests/pred_ref_allfolds.nii.gz').get_fdata().astype(np.int32)
# nnUNet outputs argmax labels; compute per-class Dice over the
# foreground classes, then take the unweighted mean. Single-fold
# MNN-int8 vs 5-fold ORT-fp16 reference is ~0.95 Dice on this T1.
dices = []
for k in range(1, int(max(new.max(), ref.max())) + 1):
a = (new == k); b = (ref == k)
if a.sum() + b.sum() == 0:
continue
dices.append((2 * (a & b).sum()) / (a.sum() + b.sum()))
mean = float(np.mean(dices)) if dices else 0.0
print(f'mean foreground Dice = {mean:.4f} ({len(dices)} classes)')
assert mean >= 0.90, f'Dice {mean:.4f} below sanity floor 0.90'
PY
# Compress the self-contained MNN/OpenCL binary with UPX (Linux +
# Windows only -- UPX's macOS Mach-O support, especially arm64, is
# unreliable). The binary self-decompresses at startup; we re-run
# --help afterwards to prove the packed binary still launches.
- name: Install UPX (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends upx-ucl
- name: Install UPX (Windows)
if: runner.os == 'Windows'
run: choco install upx -y --no-progress
- name: Compress binary with UPX
if: runner.os == 'Linux' || runner.os == 'Windows'
run: |
echo "--- before ---"; ls -l "${{ matrix.binary }}"
upx --best "${{ matrix.binary }}"
echo "--- after ---"; ls -l "${{ matrix.binary }}"
# The compressed binary must still launch (self-extracts at startup).
"${{ matrix.binary }}" --help > /dev/null && echo "OK: UPX-packed binary runs"
- name: Package
shell: bash
run: |
BASE=$(cat VERSION 2>/dev/null || echo "0.0.0")
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VER="${GITHUB_REF_NAME#v}"
else
VER="${BASE}-${GITHUB_SHA:0:7}"
fi
case "${{ runner.os }}" in
Linux) PLAT=linux-x86_64 ;;
macOS) PLAT=macos ;;
Windows) PLAT=windows-x64 ;;
esac
BUNDLE="siamize-${VER}-${PLAT}-opencl"
# `opencl` mode stages the (static or shared) MNN binary
# alone; libMNN.so is only copied if a shared build was
# staged. See scripts/package.sh.
scripts/package.sh opencl "$BUNDLE"
echo "BUNDLE=$BUNDLE" >> "$GITHUB_ENV"
- name: Upload bundle
uses: actions/upload-artifact@v4
with:
name: ${{ env.BUNDLE }}
path: dist/${{ env.BUNDLE }}
if-no-files-found: error
retention-days: 14
mex-cuda-smoke:
# Compile-only smoke test for `make cudaoct` / `make cudamex`. The
# GHA runner has no NVIDIA GPU and no CUDA runtime libs, so we can
# NOT loadlibrary the resulting MEX -- libonnxruntime_providers_cuda
# .so would dlopen and fail. Instead we just verify the build
# succeeds AND the produced .mex contains the CUDA-EP-probe strings
# that `sliding.cpp`'s `#ifdef SIAMIZE_HAS_CUDA` block emits. That
# catches the kind of regression where SIAMIZE_HAS_CUDA fails to
# propagate to the MEX (which was the bug fixed in dee8b1d).
name: mex-cuda-smoke (octave)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Octave
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends octave liboctave-dev
- name: Fetch C++ deps (GPU ORT prebuilt)
env:
ORT_BUILD: gpu
run: scripts/fetch_deps.sh
- name: Build `make cudaoct`
run: make cudaoct
- name: Verify MEX exists
run: |
ls -la matlab/siamex.mex
test -s matlab/siamex.mex
- name: Verify CUDA-EP code paths compiled in
run: |
# Strings emitted by sliding.cpp's `#ifdef SIAMIZE_HAS_CUDA`
# block. Their presence proves the CUDA EP probe code reached
# the linker; their absence means SIAMIZE_HAS_CUDA didn't
# propagate (the dee8b1d bug).
#
# Dump strings to a file first, then grep the file. Piping
# `strings ... | grep -q ...` is fragile under bash's
# `-o pipefail` (GHA default): when grep finds the match
# early it closes the pipe, strings gets SIGPIPE and exits
# non-zero, and the pipeline reports failure even though the
# match was found.
strings -a matlab/siamex.mex > /tmp/siamex_strings.txt
for needle in "probing CUDA EP" "cudnn_conv_use_max_workspace" \
"cudnn_conv_algo_search"; do
if ! grep -qF "$needle" /tmp/siamex_strings.txt; then
echo "FAIL: '$needle' not found in matlab/siamex.mex" >&2
exit 1
fi
done
echo "OK: CUDA-EP probe strings present in matlab/siamex.mex"
mex-coreml-smoke:
# macOS-only compile + strings-grep test for `make coremloct`.
# The macos-14 runner DOES have a real CoreML stack, but we keep
# this leg lightweight (compile + symbol probe only) since the
# heavier end-to-end MEX-load test would require MATLAB on Apple
# Silicon, which isn't part of the matlab-actions/setup-matlab
# macos-14 support yet. Validates SIAMIZE_HAS_COREML propagates
# to the MEX -- same kind of regression as dee8b1d but for the
# CoreML path.
name: mex-coreml-smoke (octave)
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Octave + libomp via Homebrew
run: |
brew install octave libomp
echo "OpenMP_ROOT=$(brew --prefix libomp)" >> "$GITHUB_ENV"
- name: Fetch C++ deps (CPU ORT)
run: scripts/fetch_deps.sh
- name: Build `make coremloct`
run: make coremloct
- name: Verify MEX exists
run: |
ls -la matlab/siamex.mex
test -s matlab/siamex.mex
- name: Verify CoreML EP code paths compiled in
run: |
# Strings emitted by sliding.cpp's `#ifdef SIAMIZE_HAS_COREML`
# block. Their presence proves the CoreML EP probe code reached
# the linker; their absence means SIAMIZE_HAS_COREML didn't
# propagate through the Octave MEX's CXXFLAGS path.
#
# Dump strings to a file first, then grep the file. Apple's
# BSD strings exits non-zero on SIGPIPE when grep -q closes
# the pipe early; combined with bash's `-o pipefail` (GHA
# default) that fails the pipeline even when the match was
# found. -a forces strings to read every Mach-O section,
# not just __TEXT.
strings -a matlab/siamex.mex > /tmp/siamex_strings.txt
for needle in "probing CoreML EP" "MLComputeUnits" \
"ModelCacheDirectory" "RequireStaticInputShapes"; do
if ! grep -qF "$needle" /tmp/siamex_strings.txt; then
echo "FAIL: '$needle' not found in matlab/siamex.mex" >&2
echo "--- strings related to coreml / CoreML (for triage) ---"
grep -iE "coreml|mlcompute|mlprogram|instance" \
/tmp/siamex_strings.txt | head -20
exit 1
fi
done
echo "OK: CoreML-EP probe strings present in matlab/siamex.mex"
python-smoke:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install Python deps
run: |
pip install --upgrade pip
pip install numpy nibabel scipy scikit-image torch \
dynamic_network_architectures onnxruntime
- name: Import smoke test
run: |
python -c "import sys; sys.path.insert(0, 'py'); import siam_ref; print(siam_ref.build_network.__name__)"
python tools/onnx_export/compare.py --help || true