Skip to content

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.dylib is a system library (always available). On Linux, libz.a is in zlib1g-dev. Some HDF5 builds also require szip/libaec — the implementation should check pkg-config --libs hdf5 or 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=ON path must explicitly link OpenBLAS or system BLAS/LAPACK (-lopenblas or -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.sh output 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.