diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc6c06..67b6eec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,12 @@ env: jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + # macos-13 is the last x86_64 runner; ArrayFire only ships an x86_64 + # macOS binary, so the flake has no aarch64-darwin (macos-latest) build. + os: [ubuntu-latest, macos-13] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -18,11 +23,5 @@ jobs: with: nix_path: nixpkgs=channel:nixpkgs-unstable - - name: Nix channel --update - run: nix-channel --update - - - name: Cabal update - run: nix develop --command bash -c 'cabal update' - - name: Build and run tests - run: nix develop --command bash -c 'cabal install hspec-discover && cabal test' + run: nix build -L diff --git a/flake.nix b/flake.nix index 9869b69..c8c3c30 100644 --- a/flake.nix +++ b/flake.nix @@ -25,8 +25,8 @@ ]; }; - # Build ArrayFire from the official binary installer; avoids freeimage entirely. - mkArrayfire = pkgs: pkgs.stdenv.mkDerivation rec { + # Build ArrayFire from the official Linux binary installer; avoids freeimage entirely. + mkArrayfireLinux = pkgs: pkgs.stdenv.mkDerivation rec { pname = "arrayfire"; version = "3.10.0"; src = pkgs.fetchurl { @@ -50,12 +50,98 @@ mkdir -p $out bash $src --exclude-subdir --prefix=$out ''; + # autoPatchelfIgnoreMissingDeps silences missing-dep errors at build time, + # but a genuinely-absent dep of libafcpu.so would still make its runtime + # dlopen fail with LoadLibError. Fail the build loudly if the CPU backend + # has any unresolved (=> not just intentionally-ignored GPU) dependencies. + doInstallCheck = true; + installCheckPhase = '' + libdir=$out/lib64 + [ -d "$libdir" ] || libdir=$out/lib + cpu=$(echo "$libdir"/libafcpu.so* | tr ' ' '\n' | head -n1) + echo "Checking runtime deps of $cpu" + if ldd "$cpu" | grep -i 'not found'; then + echo "ERROR: libafcpu.so has unresolved dependencies" >&2 + exit 1 + fi + ''; meta = { description = "A general-purpose library for parallel and massively-parallel architectures"; platforms = [ "x86_64-linux" ]; }; }; + # Build ArrayFire on macOS from the official .pkg installer. ArrayFire has not + # shipped a macOS binary since 3.8.2 (x86_64 only), so darwin pins that version. + # The .pkg is a xar archive of component sub-packages, each carrying a + # gzip+cpio Payload that installs under opt/arrayfire/{include,lib}. + mkArrayfireDarwin = pkgs: pkgs.stdenv.mkDerivation rec { + pname = "arrayfire"; + version = "3.8.2"; + src = pkgs.fetchurl { + url = "https://arrayfire.s3.amazonaws.com/${version}/ArrayFire-${version}_OSX_x86_64.pkg"; + hash = "sha256-MDqpDONbzl+PNu2VS1UTaYL10fpzpt0pv10oxNwgm+k="; + }; + nativeBuildInputs = with pkgs; [ xar cpio fixDarwinDylibNames ]; + # Never strip the prebuilt vendor dylibs: the default strip phase corrupts + # them (it silently truncated libmkl_core.dylib to 0 bytes, which then made + # MKL fail to load its computational layer at runtime). + dontStrip = true; + unpackPhase = '' + runHook preUnpack + xar -xf $src + runHook postUnpack + ''; + # Extract every component Payload (except the heavy CUDA/OpenCL/examples ones + # we don't ship) into a staging tree, then install only the unified + CPU + # backends and their bundled runtime deps (MKL, TBB, forge). + installPhase = '' + runHook preInstall + mkdir -p stage + for comp in ArrayFire-${version}-Darwin-*.pkg; do + case "$comp" in + *cuda*|*opencl*|*examples*|*documentation*) continue ;; + esac + [ -f "$comp/Payload" ] || continue + ( cd stage && gzip -dc "../$comp/Payload" | cpio -id --quiet ) + done + + mkdir -p $out/lib + cp -R stage/opt/arrayfire/include $out/include + for pat in 'libaf.*' 'libafcpu.*' 'libforge.*' 'libmkl_*.dylib' \ + 'libtbb*.dylib' 'libiomp*.dylib'; do + cp -P stage/opt/arrayfire/lib/$pat $out/lib/ 2>/dev/null || true + done + runHook postInstall + ''; + # fixDarwinDylibNames (run in fixupPhase) rewrites the @rpath install ids + # and matching inter-library references to absolute store paths. It only + # rewrites references whose leaf matches a sibling's *original* id, so it + # misses cases where the ids differ, e.g. libafcpu -> @rpath/libmkl_rt and + # libmkl_tbb_thread -> @rpath/libtbb (the latter is dlopen'd by MKL's + # libmkl_rt and would otherwise fail to load at runtime). Re-point any + # remaining @rpath/ dep at $out/lib/ so everything is hermetic. + postFixup = '' + for dylib in $out/lib/*.dylib; do + for dep in $(otool -L "$dylib" | awk 'NR>1{print $1}' | grep '^@rpath/' || true); do + leaf=''${dep#@rpath/} + if [ -e "$out/lib/$leaf" ]; then + install_name_tool -change "$dep" "$out/lib/$leaf" "$dylib" + fi + done + done + ''; + meta = { + description = "A general-purpose library for parallel and massively-parallel architectures"; + platforms = [ "x86_64-darwin" ]; + }; + }; + + mkArrayfire = pkgs: + if pkgs.stdenv.isDarwin + then mkArrayfireDarwin pkgs + else mkArrayfireLinux pkgs; + arrayfire-overlay = self: super: { arrayfire = mkArrayfire self; }; @@ -65,11 +151,37 @@ haskell = super.haskell // { packageOverrides = inputs.nixpkgs.lib.composeExtensions super.haskell.packageOverrides (hself: hsuper: { - arrayfire = self.haskell.lib.appendConfigureFlags - (hself.callCabal2nix "arrayfire" src { - af = self.arrayfire; - }) - [ "-f disable-default-paths" ]; + arrayfire = + let + pkg = self.haskell.lib.appendConfigureFlags + (hself.callCabal2nix "arrayfire" src { + af = self.arrayfire; + }) + [ "-f disable-default-paths" ]; + in + # On macOS ArrayFire's bundled MKL dlopens its threading layer + # (libmkl_tbb_thread.dylib) by bare leaf name, which dyld only + # resolves via DYLD_LIBRARY_PATH. Point it at the arrayfire libs + # so the test suite (and doctests) can run. Runtime consumers of + # this package need the same DYLD_LIBRARY_PATH. + if self.stdenv.isDarwin + then pkg.overrideAttrs (old: { + preCheck = (old.preCheck or "") + '' + export DYLD_LIBRARY_PATH="${self.arrayfire}/lib''${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" + ''; + }) + # On Linux we link against the unified backend (libaf), which is + # just a dispatcher that dlopens the real backend impl + # (libafcpu.so) at runtime. The sandboxed check phase has no + # LD_LIBRARY_PATH/AF_PATH, so that dlopen finds nothing and every + # test throws AFException LoadLibError (501). Point the loader at + # the arrayfire libs so the backend can be found. + else pkg.overrideAttrs (old: { + preCheck = (old.preCheck or "") + '' + export AF_PATH="${self.arrayfire}" + export LD_LIBRARY_PATH="${self.arrayfire}/lib:${self.arrayfire}/lib64''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + ''; + }); }); }; }; @@ -77,25 +189,31 @@ devShell-for = pkgs: let ps = pkgs.haskellPackages; + isLinux = pkgs.stdenv.isLinux; + isDarwin = pkgs.stdenv.isDarwin; + # ArrayFire only ships an x86_64 macOS binary, so it's unavailable on + # Apple Silicon; fall back to a plain shell there. + hasArrayfire = isLinux || pkgs.stdenv.hostPlatform.system == "x86_64-darwin"; in - ps.shellFor { - packages = ps: with ps; [ arrayfire ]; - withHoogle = true; - buildInputs = with pkgs; [ ocl-icd ]; - nativeBuildInputs = with pkgs; with ps; [ - # Building and testing - cabal-install - doctest - hsc2hs - # hspec-discover - nil - # Formatters - nixpkgs-fmt - ]; - shellHook = '' - export LD_LIBRARY_PATH="${pkgs.arrayfire}/lib:$LD_LIBRARY_PATH" - ''; - }; + ps.shellFor { + packages = ps: if hasArrayfire then [ ps.arrayfire ] else [ ]; + withHoogle = true; + buildInputs = with pkgs; (if isLinux then [ ocl-icd ] else [ darwin.apple_sdk.frameworks.Security ]); + nativeBuildInputs = with pkgs; with ps; [ + # Building and testing + cabal-install + doctest + hsc2hs + # hspec-discover + nil + # Formatters + nixpkgs-fmt + ]; + shellHook = + if isLinux then ''export LD_LIBRARY_PATH="${pkgs.arrayfire}/lib:$LD_LIBRARY_PATH"'' + else if hasArrayfire then ''export DYLD_LIBRARY_PATH="${pkgs.arrayfire}/lib:$DYLD_LIBRARY_PATH"'' + else ""; + }; pkgs-for = system: import inputs.nixpkgs { inherit system; @@ -107,8 +225,13 @@ in { packages = inputs.flake-utils.lib.eachDefaultSystemMap (system: - with (pkgs-for system); { - default = haskellPackages.arrayfire; + let + pkgs = pkgs-for system; + # ArrayFire only provides binaries for x86_64-linux and x86_64-darwin + # (no Apple Silicon / aarch64), so only expose the package there. + hasArrayfire = pkgs.stdenv.isLinux || system == "x86_64-darwin"; + in inputs.nixpkgs.lib.optionalAttrs hasArrayfire { + default = pkgs.haskellPackages.arrayfire; }); devShells = inputs.flake-utils.lib.eachDefaultSystemMap (system: { diff --git a/test/Main.hs b/test/Main.hs index c949527..bc92605 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -4,6 +4,7 @@ module Main where import Control.Monad +import Data.Maybe (isJust) import Data.Proxy import Spec (spec) import Test.Hspec (hspec) @@ -13,6 +14,7 @@ import Test.QuickCheck.Classes import qualified ArrayFire as A import ArrayFire (Array) +import System.Environment (lookupEnv) import System.IO.Unsafe instance (A.AFType a, Arbitrary a) => Arbitrary (Array a) where @@ -20,7 +22,11 @@ instance (A.AFType a, Arbitrary a) => Arbitrary (Array a) where main :: IO () main = do - A.setBackend A.CPU + -- In CI there's often no GPU/OpenCL device available, which makes the + -- default backend throw (e.g. cl::Error: clGetDeviceIDs). Fall back to + -- the CPU backend when running in CI. + -- inCI <- isJust <$> lookupEnv "CI" + -- when (not inCI) (A.setBackend A.Default) -- checks (Proxy :: Proxy (A.Array (A.Complex Float))) -- checks (Proxy :: Proxy (A.Array (A.Complex Double))) -- checks (Proxy :: Proxy (A.Array Double))