Static Linking of Third-Party Dependencies for Distribution¶
Date: 2026-03-25 Status: Approved Issue: #22
Problem¶
FORGE binaries dynamically link against several third-party libraries (Boost, Armadillo, FFTW, HDF5, ISMRMRD, OpenMP). For bundling inside FORGE Studio's Electron app, these need to be statically linked so the installed binaries are self-contained — users shouldn't need Homebrew or system packages to run the app.
Goals¶
- Installed binaries depend only on
libForgeCommon_<backend>.dylib/.so, system frameworks/runtime, and OpenMP - Developer workflow (
STATIC_DEPS=OFF, the default) is unchanged - Targets: macOS (Apple Silicon) and Linux (x86_64 / ARM64)
Design¶
scripts/build-deps.sh — Static dependency builder¶
A shell script that builds Armadillo and ISMRMRD as static libraries into a local prefix. These are the only two dependencies that don't ship static .a files from package managers.
scripts/build-deps.sh [--prefix deps/install] [--jobs 4]
What it builds:
1. ISMRMRD v1.15.0 — cmake -DBUILD_SHARED_LIBS=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON
2. Armadillo 15.2.3 — cmake -DBUILD_SHARED_LIBS=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON
The script is idempotent — skips if the prefix already has the libs. Output:
deps/install/
├── include/ # armadillo, ismrmrd headers
├── lib/
│ ├── libarmadillo.a
│ └── libismrmrd.a
└── lib/cmake/ # find_package configs
STATIC_DEPS CMake option¶
A new option -DSTATIC_DEPS=ON (default OFF) controls static linking behavior.
Important: Toggling STATIC_DEPS requires a clean build directory (or cmake --fresh). CMake caches find_library/find_package results, so switching from dynamic to static on an existing build dir will not re-find libraries.
| Library | Mechanism |
|---|---|
| spdlog | Already static (PR #23) |
| Boost (program_options, serialization) | set(Boost_USE_STATIC_LIBS ON) before find_package(Boost) |
| FFTW (fftw3, fftw3f) | Save/restore CMAKE_FIND_LIBRARY_SUFFIXES around FFTW find_library() calls — set to .a only for FFTW, then restore to default so subsequent finds (OpenMP, etc.) are unaffected |
| HDF5 (hdf5, hdf5_cpp) | set(HDF5_USE_STATIC_LIBRARIES ON) + explicitly link transitive deps (zlib; szip/libaec if present) |
| Armadillo | Pass -DARMA_HOME=deps/install or set ARMADILLO_LIBRARY cache var directly to deps/install/lib/libarmadillo.a — the existing cmake/Modules/FindArmadillo.cmake uses $ENV{ARMA_HOME}, not CMAKE_PREFIX_PATH |
| ISMRMRD | Override ISMRMRD_LIBRARIES cache var to point at deps/install/lib/libismrmrd.a — the existing find_library(... HINTS /usr/lib/) would find the system .so first otherwise |
| MPI | Forced OFF (MPISupport=OFF) |
| OpenMP | Stays dynamic (system runtime) |
Transitive dependencies¶
Some static libraries have their own dependencies that must be linked explicitly:
- HDF5 static requires zlib (
-lz). On macOS,/usr/lib/libz.dylibis a system library (always available). On Linux,libz.ais inzlib1g-dev. Some HDF5 builds also require szip/libaec — the implementation should checkpkg-config --libs hdf5or the HDF5 CMake config for the full list. - Armadillo static requires BLAS/LAPACK. On macOS, the Accelerate framework provides these (already linked). On Linux, the
STATIC_DEPS=ONpath must explicitly link OpenBLAS or system BLAS/LAPACK (-lopenblasor-lblas -llapack). - ISMRMRD static depends on HDF5 (handled above) and Boost serialization (handled above).
Position-independent code¶
Static libraries are embedded into the shared libForgeCommon_<backend>.dylib/.so. This requires all .a files to be compiled with -fPIC. Homebrew (macOS) builds static libs with PIC by default. On Linux (apt), libfftw3-dev and other -dev packages ship PIC-enabled .a files on Ubuntu 22.04+. For deps built by build-deps.sh, CMAKE_POSITION_INDEPENDENT_CODE=ON is passed explicitly.
LTO interaction¶
The macOS build uses -flto globally. Static FFTW contains SIMD assembly that LTO doesn't handle well (test binaries already disable LTO with -fno-lto). When STATIC_DEPS=ON, ForgeCommon_<backend> targets should also use -fno-lto to avoid miscompilation of embedded FFTW routines. Alternatively, the FFTW .a files can be linked with -Wl,-no_lto_library (macOS linker flag) to exclude them from LTO while keeping LTO for forge's own code.
Runtime dependencies with STATIC_DEPS=ON¶
| Dependency | macOS | Linux |
|---|---|---|
| libForgeCommon_ |
.dylib (bundled) |
.so (bundled) |
| libomp / libgomp | .dylib (bundled by forge-studio) |
.so (system GCC runtime) |
| zlib | System (/usr/lib/libz) |
System |
| Accelerate.framework | System | N/A |
| Metal.framework | System (metal backend) | N/A |
| libc++ / libstdc++ | System | System |
| CUDA runtime | N/A | System (cuda backend) |
Build workflow¶
Developer (unchanged):
cmake -B build -S . -DMETAL_COMPUTE=ON
cmake --build build -j4
Distribution:
# Step 1: Build static deps (once, cached)
./scripts/build-deps.sh --prefix deps/install
# Step 2: Configure with static deps (clean build dir required)
ARMA_HOME=deps/install cmake -B build_dist -S . \
-DSTATIC_DEPS=ON -DMETAL_COMPUTE=ON \
-DISMRMRD_LIBRARIES=deps/install/lib/libismrmrd.a
# Step 3: Build and install
cmake --build build_dist -j4
cmake --install build_dist --prefix /path/to/install
CI integration¶
build-deps.shoutput cached by GitHub Actions (keyed on dep versions)- Distribution build is a separate CI job from regular test builds
- Verification checks
libForgeCommon_<backend>(where deps are embedded), not just executables:# macOS — check the library for non-system dynamic deps otool -L install/lib/libForgeCommon_metal.dylib | grep -v /usr/lib | grep -v /System | grep -v @rpath otool -L install/bin/metal/forgeSense | grep -v /usr/lib | grep -v /System | grep -v @rpath # Should only show libomp.dylib # Linux ldd install/lib/libForgeCommon_cpu.so | grep "not found" ldd install/bin/cpu/forgeSense | grep "not found" # Should show nothing
Scope exclusions¶
- forgeview — links FTXUI and nlohmann/json (both FetchContent, already static). Does not link ForgeCommon. No changes needed.
- SuperLU — listed in CLAUDE.md as a dependency but not currently used in CMakeLists.txt. Excluded.
- indicators — FetchContent, header-only. No changes needed.
What STATIC_DEPS=OFF preserves¶
Everything behaves exactly as today — dynamic linking against system libraries, MPI optional, no need to run build-deps.sh. The developer workflow is completely unaffected.