From cdafef6bc8193498ceff8cac1ea5cb54cb443cac Mon Sep 17 00:00:00 2001 From: "code@dmj.io" Date: Sat, 6 Jun 2026 10:30:36 -0500 Subject: [PATCH 1/9] Add darwin build and dev support. --- .github/workflows/ci.yml | 5 ++++- flake.nix | 36 ++++++++++++++++++------------------ test/Main.hs | 3 ++- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc6c06..5d3a9eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,10 @@ env: jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest] # Add macOS to the matrix + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/flake.nix b/flake.nix index 9869b69..1a05fde 100644 --- a/flake.nix +++ b/flake.nix @@ -77,25 +77,25 @@ devShell-for = pkgs: let ps = pkgs.haskellPackages; + isLinux = pkgs.stdenv.isLinux; + isDarwin = pkgs.stdenv.isDarwin; 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 isLinux 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 ""; + }; pkgs-for = system: import inputs.nixpkgs { inherit system; diff --git a/test/Main.hs b/test/Main.hs index c949527..4177986 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -20,7 +20,8 @@ instance (A.AFType a, Arbitrary a) => Arbitrary (Array a) where main :: IO () main = do - A.setBackend A.CPU + A.setBackend A.Default + A.info -- checks (Proxy :: Proxy (A.Array (A.Complex Float))) -- checks (Proxy :: Proxy (A.Array (A.Complex Double))) -- checks (Proxy :: Proxy (A.Array Double)) From aaeaefcc66017318d1c1223ae6e47c42337dfd0e Mon Sep 17 00:00:00 2001 From: "code@dmj.io" Date: Sat, 6 Jun 2026 11:47:07 -0500 Subject: [PATCH 2/9] Add ArrayFire build --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3a9eb..1991437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,16 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install ArrayFire (macOS) + if: runner.os == 'macOS' + run: | + set -euo pipefail + brew install arrayfire + # The cabal file's OSX default paths are hardcoded to /opt/arrayfire; + # point them at the Homebrew install so the build finds the headers, + # libs, and rpath. + sudo ln -sfn "$(brew --prefix arrayfire)" /opt/arrayfire + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixpkgs-unstable From 8abd90823ce757eb2691f079bb0979515b54251f Mon Sep 17 00:00:00 2001 From: "code@dmj.io" Date: Sat, 6 Jun 2026 11:47:43 -0500 Subject: [PATCH 3/9] Update flake.nix --- flake.nix | 119 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 11 deletions(-) diff --git a/flake.nix b/flake.nix index 1a05fde..45582ab 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 { @@ -56,6 +56,77 @@ }; }; + # 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 +136,26 @@ 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}" + ''; + }) + else pkg; }); }; }; @@ -79,9 +165,12 @@ 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: if isLinux then [ ps.arrayfire ] else [ ]; + 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; [ @@ -94,7 +183,10 @@ # Formatters nixpkgs-fmt ]; - shellHook = if isLinux then ''export LD_LIBRARY_PATH="${pkgs.arrayfire}/lib:$LD_LIBRARY_PATH"'' else ""; + 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 { @@ -107,8 +199,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: { From 18c44286d4ac0e0db7285831afef1b51d9f8ebf4 Mon Sep 17 00:00:00 2001 From: "code@dmj.io" Date: Sat, 6 Jun 2026 12:20:40 -0500 Subject: [PATCH 4/9] Use CPU in CI --- test/Main.hs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/Main.hs b/test/Main.hs index 4177986..dff284b 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.Default + -- 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" + A.setBackend (if inCI then A.CPU else A.Default) A.info -- checks (Proxy :: Proxy (A.Array (A.Complex Float))) -- checks (Proxy :: Proxy (A.Array (A.Complex Double))) From 09d6b8caea8ed216abeb754b620050b22fd60bf7 Mon Sep 17 00:00:00 2001 From: "code@dmj.io" Date: Sat, 6 Jun 2026 12:29:42 -0500 Subject: [PATCH 5/9] drop `info` --- test/Main.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Main.hs b/test/Main.hs index dff284b..adcb0c9 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -27,7 +27,6 @@ main = do -- the CPU backend when running in CI. inCI <- isJust <$> lookupEnv "CI" A.setBackend (if inCI then A.CPU else A.Default) - A.info -- checks (Proxy :: Proxy (A.Array (A.Complex Float))) -- checks (Proxy :: Proxy (A.Array (A.Complex Double))) -- checks (Proxy :: Proxy (A.Array Double)) From 48902b2015d47181bed9bd891da7fd1334853c0b Mon Sep 17 00:00:00 2001 From: "code@dmj.io" Date: Sat, 6 Jun 2026 12:55:07 -0500 Subject: [PATCH 6/9] Conditional setBackend --- test/Main.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Main.hs b/test/Main.hs index adcb0c9..e530f2f 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -26,7 +26,7 @@ main = do -- default backend throw (e.g. cl::Error: clGetDeviceIDs). Fall back to -- the CPU backend when running in CI. inCI <- isJust <$> lookupEnv "CI" - A.setBackend (if inCI then A.CPU else A.Default) + 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)) From cc65036ae84d0c58271518a3a4da2ca1ca9d7e9f Mon Sep 17 00:00:00 2001 From: David Johnson <875324+dmjio@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:31:38 -0500 Subject: [PATCH 7/9] Disable CI GPU/OpenCL backend setup Comment out code related to CI GPU/OpenCL backend handling. --- test/Main.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Main.hs b/test/Main.hs index e530f2f..bc92605 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -25,8 +25,8 @@ main = do -- 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) + -- 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)) From 693bc498081b77ce3fe0c017b116a6eed060dc74 Mon Sep 17 00:00:00 2001 From: "code@dmj.io" Date: Sat, 6 Jun 2026 20:08:50 -0500 Subject: [PATCH 8/9] CI: build via `nix build -L`, drop brew and cabal flow Replace the cabal-in-devShell test flow with `nix build -L`, which builds the package derivation and runs the hspec suite in its checkPhase. Drop the redundant `brew install arrayfire` (ArrayFire is provided by the flake's .pkg-based darwin build) and pin macOS to macos-13 since ArrayFire only ships an x86_64 macOS binary. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 22 ++++------------------ flake.nix | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1991437..67b6eec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,30 +12,16 @@ jobs: build: strategy: matrix: - os: [ubuntu-latest, macos-latest] # Add macOS to the 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 - - name: Install ArrayFire (macOS) - if: runner.os == 'macOS' - run: | - set -euo pipefail - brew install arrayfire - # The cabal file's OSX default paths are hardcoded to /opt/arrayfire; - # point them at the Homebrew install so the build finds the headers, - # libs, and rpath. - sudo ln -sfn "$(brew --prefix arrayfire)" /opt/arrayfire - - uses: cachix/install-nix-action@v31 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 45582ab..c8c3c30 100644 --- a/flake.nix +++ b/flake.nix @@ -50,6 +50,21 @@ 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" ]; @@ -155,7 +170,18 @@ export DYLD_LIBRARY_PATH="${self.arrayfire}/lib''${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" ''; }) - else pkg; + # 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}" + ''; + }); }); }; }; From 741483453e83087e16b245ec746e38ff3aa8c2ce Mon Sep 17 00:00:00 2001 From: "code@dmj.io" Date: Sun, 7 Jun 2026 12:19:53 -0500 Subject: [PATCH 9/9] Use Rosetta, `macos-latest` --- .github/workflows/ci.yml | 21 +++++++++++++++++---- test/Main.hs | 8 +------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67b6eec..df8e918 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,16 +12,29 @@ jobs: build: 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] + os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + # macos-latest is Apple Silicon, but ArrayFire only ships an x86_64 macOS + # binary, so the flake's darwin output is x86_64-darwin. Build it under + # Rosetta 2: ensure Rosetta is present and let Nix build x86_64-darwin + # derivations via `extra-platforms` below. + - name: Install Rosetta 2 + if: runner.os == 'macOS' + run: softwareupdate --install-rosetta --agree-to-license + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixpkgs-unstable + extra_nix_config: | + extra-platforms = x86_64-darwin - - name: Build and run tests + - name: Build and run tests (Linux) + if: runner.os == 'Linux' run: nix build -L + + - name: Build and run tests (macOS, x86_64 via Rosetta) + if: runner.os == 'macOS' + run: nix build -L .#packages.x86_64-darwin.default diff --git a/test/Main.hs b/test/Main.hs index bc92605..c949527 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -4,7 +4,6 @@ module Main where import Control.Monad -import Data.Maybe (isJust) import Data.Proxy import Spec (spec) import Test.Hspec (hspec) @@ -14,7 +13,6 @@ 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 @@ -22,11 +20,7 @@ instance (A.AFType a, Arbitrary a) => Arbitrary (Array a) where main :: IO () main = do - -- 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) + A.setBackend A.CPU -- checks (Proxy :: Proxy (A.Array (A.Complex Float))) -- checks (Proxy :: Proxy (A.Array (A.Complex Double))) -- checks (Proxy :: Proxy (A.Array Double))