Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions recipes/flet-libopenblas/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/bin/bash
set -eu

# OpenBLAS is make-based (not autotools). Cross-compile knobs:
# TARGET/BINARY — required for cross (OpenBLAS can't probe the target CPU)
# NOFORTRAN=1 — build BLAS + f2c-converted LAPACK (no Fortran compiler)
# CROSS=1 — never run target binaries during the build
# HOSTCC=clang — build-machine compiler for getarch and friends
case "$HOST_ARCH" in
arm64-v8a|arm64) TARGET=ARMV8; BINARY=64 ;;
armeabi-v7a) TARGET=ARMV7; BINARY=32 ;;
x86_64) TARGET=NEHALEM; BINARY=64 ;;
*) echo "flet-libopenblas: unsupported arch '$HOST_ARCH'"; exit 1 ;;
esac

# `libs netlib` builds the BLAS + f2c-LAPACK static lib but skips OpenBLAS's
# self-test binaries (the `tests` target), whose ARMV7 link pulls a hard-float
# -lm_hard that the softfp NDK lacks. The .a is identical; tests aren't needed.
make libs netlib \
TARGET="$TARGET" BINARY="$BINARY" \
HOSTCC=clang CC="$CC" AR="$AR" RANLIB=echo \
NOFORTRAN=1 CROSS=1 \
USE_THREAD=0 NUM_THREADS=1 \
NO_SHARED=1 \
CFLAGS="$CFLAGS" \
-j"$CPU_COUNT"

make PREFIX="$PREFIX" TARGET="$TARGET" NO_SHARED=1 install
rm -rf "$PREFIX/lib/cmake"

# OpenBLAS installs libopenblas.a as a symlink to the versioned archive; forge's
# copytree dereferences it into two 60 MB files. Collapse to one real libopenblas.a.
ver_a=$(ls "$PREFIX"/lib/libopenblas_*.a 2>/dev/null | head -1 || true)
if [ -n "${ver_a:-}" ]; then
rm -f "$PREFIX/lib/libopenblas.a"
mv "$ver_a" "$PREFIX/lib/libopenblas.a"
fi

# Fix openblas.pc for consumers (scipy's meson `dependency('openblas')`):
# - relocatable libdir/includedir (the baked-in absolute build paths are gone
# by the time scipy builds against the installed host wheel)
# - drop extralib's `-lgfortran` (we built NOFORTRAN; the f2c LAPACK lives inside
# libopenblas and there is no libgfortran on iOS/Android) and `-lpthread`
# (USE_THREAD=0; pthread is in libc on both platforms).
pc="$PREFIX/lib/pkgconfig/openblas.pc"
sed -i.bak \
-e 's|^libdir=.*|libdir=${pcfiledir}/..|' \
-e 's|^includedir=.*|includedir=${pcfiledir}/../../include|' \
-e 's|^extralib=.*|extralib=|' \
"$pc"
rm -f "$pc.bak"
11 changes: 11 additions & 0 deletions recipes/flet-libopenblas/meta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% set version = "0.3.33" %}

package:
name: flet-libopenblas
version: '{{ version }}'

build:
number: 0

source:
url: https://github.com/OpenMathLib/OpenBLAS/releases/download/v{{ version }}/OpenBLAS-{{ version }}.tar.gz
42 changes: 42 additions & 0 deletions recipes/scipy/meta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package:
name: scipy
version: '1.18.0'

requirements:
build:
- ninja
- meson
- cython
- pythran
host:
- numpy 2.4.6
- pybind11
- flet-libopenblas 0.3.33
# {% if sdk == 'android' %}
- flet-libcpp-shared >=27.2.12479018
# {% endif %}

patches:
- android-bionic-clog-cpow.patch
- android-ducc-no-affinity.patch
- android-x86_64-boost-longdouble.patch

build:
number: 1
backend-args:
# scipy 1.18 builds without a Fortran compiler — only the deprecated
# scipy.odr is dropped. Removes the cross-Fortran blocker entirely.
- -Csetup-args=-D_without-fortran=true
# BLAS/LAPACK from flet-libopenblas (OpenBLAS, NOFORTRAN f2c-LAPACK) on both
# platforms, resolved via its openblas.pc. Standard Fortran mangling.
- -Csetup-args=-Dblas=openblas
- -Csetup-args=-Dlapack=openblas
# {% if sdk == 'android' %}
# pythran 0.18.1's pythonic/types/ndarray.hpp has an ill-formed ref-qualifier
# overload that the newer NDK clang rejects (Apple clang accepts it, so iOS
# keeps pythran). pythran is an optional scipy build dep — disable it on
# Android; the affected modules fall back to their cython/numpy versions.
- -Csetup-args=-Duse-pythran=false
# {% endif %}
- -Csetup-args=--cross-file
- -Csetup-args={MESON_CROSS_FILE}
28 changes: 28 additions & 0 deletions recipes/scipy/patches/android-bionic-clog-cpow.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Android bionic only exposes the C99 complex functions clog() and cpow() from
# API level 26 (verified via llvm-nm: API 24's libm has cabs/carg/cexp/csin/
# ccos/csqrt but not clog/cpow). scipy.special's _complexstuff.h assumes libc
# provides them. Define them for __ANDROID_API__ < 26 using functions present
# since API 24. No-op on iOS and Android >= 26 (so harmless to apply always).
--- a/scipy/special/_complexstuff.h
+++ b/scipy/special/_complexstuff.h
@@ -16,6 +16,20 @@
#include <numpy/npy_common.h>
#include "scipy_complex_support.h"

+/* bionic (Android libc) only declares the C99 complex log/pow from API level
+ * 26; scipy.special assumes libc provides them. Supply them for older Android
+ * targets in terms of functions bionic has had since API 24 (log/cabs/carg/
+ * cexp). The guard makes this a no-op on iOS and on Android API >= 26. */
+#if defined(__ANDROID__) && __ANDROID_API__ < 26
+#include <math.h>
+static inline double complex clog(double complex z) {
+ return log(cabs(z)) + carg(z) * I;
+}
+static inline double complex cpow(double complex x, double complex y) {
+ return cexp(y * clog(x));
+}
+#endif
+
#if defined(_MSC_VER)
typedef _Dcomplex _scipy_dz;
#else
34 changes: 34 additions & 0 deletions recipes/scipy/patches/android-ducc-no-affinity.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# scipy.fft vendors ducc0, whose threading.cc pins threads via the glibc-only
# pthread_{get,set}affinity_np, guarded on `__linux__ && _GNU_SOURCE`. Android is
# __linux__ but bionic lacks those _np functions, so the guard wrongly fires.
# Exclude __ANDROID__ so ducc falls back to its no-affinity path (CPU pinning is
# only a perf optimization). No-op on iOS (not __linux__) and desktop Linux.
--- a/subprojects/duccfft/ducc0/infra/threading.cc
+++ b/subprojects/duccfft/ducc0/infra/threading.cc
@@ -69,7 +69,7 @@
#include <string.h>
#if __has_include(<pthread.h>)
#include <pthread.h>
-#if __has_include(<pthread.h>) && defined(__linux__) && defined(_GNU_SOURCE)
+#if __has_include(<pthread.h>) && defined(__linux__) && defined(_GNU_SOURCE) && !defined(__ANDROID__)
#include <unistd.h>
#endif
#endif
@@ -111,7 +111,7 @@
{
static const size_t available_hardware_threads_ = []()
{
-#if __has_include(<pthread.h>) && defined(__linux__) && defined(_GNU_SOURCE)
+#if __has_include(<pthread.h>) && defined(__linux__) && defined(_GNU_SOURCE) && !defined(__ANDROID__)
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
pthread_getaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
@@ -202,7 +202,7 @@
bool empty() const { return size_==0; }
};

-#if __has_include(<pthread.h>) && defined(__linux__) && defined(_GNU_SOURCE)
+#if __has_include(<pthread.h>) && defined(__linux__) && defined(_GNU_SOURCE) && !defined(__ANDROID__)
static void do_pinning(int ithread)
{
if (pin_info()==-1) return;
24 changes: 24 additions & 0 deletions recipes/scipy/patches/android-x86_64-boost-longdouble.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Android x86_64 (bionic) uses 128-bit IEEE-quad long double (LDBL_MANT_DIG==113),
# unlike desktop x86_64's 80-bit extended. scipy's vendored boost.math selects its
# long-double bit layout by CPU macro: the `defined(__x86_64__)` branch assumes the
# 80-bit format and hard-asserts LDBL_MANT_DIG==64, which fails on android x86_64.
# boost already has the correct path right below it — `#elif (LDBL_MANT_DIG == 113)`
# ("all other processors", IEEE binary128). Gate the x86 branch on ==64 so android
# x86_64 falls through to that 113 path. Desktop x86_64 (==64) is unaffected; arm/iOS
# never match the x86 branch. (arm64 long double is also 113 but takes the native
# path; armeabi-v7a long double is 64-bit/double → the ==53 branch.)
--- a/subprojects/boost_math/math/include/boost/math/special_functions/detail/fp_traits.hpp
+++ b/subprojects/boost_math/math/include/boost/math/special_functions/detail/fp_traits.hpp
@@ -305,9 +305,10 @@

// long double (>64 bits), x86 and x64 -----------------------------------------

-#elif defined(__i386) || defined(__i386__) || defined(_M_IX86) \
+#elif (defined(__i386) || defined(__i386__) || defined(_M_IX86) \
|| defined(__amd64) || defined(__amd64__) || defined(_M_AMD64) \
- || defined(__x86_64) || defined(__x86_64__) || defined(_M_X64)
+ || defined(__x86_64) || defined(__x86_64__) || defined(_M_X64)) \
+ && (LDBL_MANT_DIG == 64)

// Intel extended double precision format (80 bits)

117 changes: 117 additions & 0 deletions recipes/scipy/test_scipy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import numpy as np


def test_linalg_solve():
"""linalg.solve -> LAPACK getrf/getrs through OpenBLAS."""
from scipy import linalg

a = np.array([[3.0, 1.0], [1.0, 2.0]])
b = np.array([9.0, 8.0])
x = linalg.solve(a, b)
assert np.allclose(a @ x, b)
assert np.allclose(x, [2.0, 3.0])


def test_linalg_svd():
"""linalg.svd -> LAPACK gesdd; reconstruct the matrix from U S Vt."""
from scipy import linalg

m = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
u, s, vt = linalg.svd(m, full_matrices=False)
assert np.allclose(u @ np.diag(s) @ vt, m)
assert s[0] > s[1] > 0


def test_linalg_eigh():
"""eigvalsh -> symmetric eigensolver (LAPACK syevd) through OpenBLAS."""
from scipy import linalg

a = np.array([[2.0, 1.0], [1.0, 2.0]])
w = np.sort(linalg.eigvalsh(a))
assert np.allclose(w, [1.0, 3.0])


def test_linalg_cholesky():
"""cholesky -> LAPACK potrf of an SPD matrix; L @ L.T reconstructs it."""
from scipy import linalg

a = np.array([[4.0, 2.0], [2.0, 3.0]])
L = linalg.cholesky(a, lower=True)
assert np.allclose(L @ L.T, a)


def test_fft():
"""scipy.fft (vendored ducc) -> fft/ifft roundtrip; DC term equals the sum."""
from scipy import fft

x = np.array([1.0, 2.0, 1.0, -1.0, 1.5, 0.0, 0.0, 0.0])
y = fft.fft(x)
assert np.allclose(y[0].real, x.sum())
assert np.allclose(fft.ifft(y).real, x)


def test_special_real():
"""special.gamma(0.5) == sqrt(pi); gamma(5) == 4!; erf(0) == 0."""
import math
from scipy import special

assert abs(special.gamma(0.5) - math.sqrt(math.pi)) < 1e-12
assert abs(special.gamma(5.0) - 24.0) < 1e-9
assert abs(special.erf(0.0)) < 1e-15


def test_special_complex():
"""Complex special function -> exercises scipy.special's C99 complex math
(clog/cpow in _complexstuff.h). loggamma(1) == 0; loggamma(1+1j) is finite."""
from scipy import special

assert abs(special.loggamma(1.0)) < 1e-12
z = special.loggamma(1 + 1j)
assert np.isfinite(z.real) and np.isfinite(z.imag)


def test_sparse_spsolve():
"""sparse CSC matrix + sparse.linalg.spsolve (SuperLU)."""
from scipy import sparse
from scipy.sparse.linalg import spsolve

a = sparse.csc_matrix([[3.0, 1.0], [1.0, 2.0]])
b = np.array([9.0, 8.0])
x = spsolve(a, b)
assert np.allclose(x, [2.0, 3.0])


def test_optimize():
"""optimize.minimize (BFGS) on a quadratic -> minimum at (1, 2.5)."""
from scipy import optimize

res = optimize.minimize(
lambda p: (p[0] - 1.0) ** 2 + (p[1] - 2.5) ** 2, [0.0, 0.0], method="BFGS"
)
assert res.success
assert np.allclose(res.x, [1.0, 2.5], atol=1e-4)


def test_integrate():
"""integrate.quad of x^2 over [0, 1] == 1/3 (QUADPACK, f2c)."""
from scipy import integrate

val, _ = integrate.quad(lambda x: x**2, 0.0, 1.0)
assert abs(val - 1.0 / 3.0) < 1e-10


def test_interpolate():
"""interpolate.interp1d -> linear interp of a known line (FITPACK, f2c)."""
from scipy import interpolate

x = np.array([0.0, 1.0, 2.0])
f = interpolate.interp1d(x, 2.0 * x + 1.0)
assert abs(float(f(0.5)) - 2.0) < 1e-12


def test_stats():
"""stats.norm -> standard-normal cdf(0) == 0.5, pdf(0) == 1/sqrt(2*pi)."""
from scipy import stats

assert abs(stats.norm.cdf(0.0) - 0.5) < 1e-12
assert abs(stats.norm.pdf(0.0) - 1.0 / np.sqrt(2.0 * np.pi)) < 1e-12
20 changes: 16 additions & 4 deletions src/forge/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,10 @@ def prepare(self, clean=True):
# host_build deps install into the cross env like host deps (so the build
# can link them), but are NOT promoted to the wheel's Requires-Dist (that
# loop below only walks "host"). For statically-linked native libs.
log(self.log_file, f"\n[{self.cross_venv}] Install forge host_build requirements")
log(
self.log_file,
f"\n[{self.cross_venv}] Install forge host_build requirements",
)
self.install_requirements("host_build")
self.fix_host_tool_shims()

Expand Down Expand Up @@ -458,9 +461,18 @@ def compile_env(self, **kwargs) -> dict[str, str]:
/ "site-packages"
)
if site_dir.is_dir():
for share_pkgconfig in site_dir.glob("*/share/pkgconfig"):
if share_pkgconfig.is_dir():
pkg_config_paths.append(str(share_pkgconfig))
# Wheels bundle their .pc files in assorted spots under
# site-packages: pybind11 -> <pkg>/share/pkgconfig, while
# numpy ships numpy/_core/lib/pkgconfig/numpy.pc. Search the
# known locations so meson's pkg-config method resolves them.
for pattern in (
"*/share/pkgconfig",
"*/lib/pkgconfig",
"*/_core/lib/pkgconfig",
):
for wheel_pc in site_dir.glob(pattern):
if wheel_pc.is_dir():
pkg_config_paths.append(str(wheel_pc))
pkg_config_path = ":".join(pkg_config_paths)

env = {
Expand Down
Loading