Skip to content

Commit af854b0

Browse files
committed
require f2py to only wrap the necessary routines
1 parent 3f58da5 commit af854b0

19 files changed

Lines changed: 1161 additions & 615 deletions

.github/workflows/blas-lapack.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ jobs:
4242
"meson==1.11.2" \
4343
"ninja==1.13.0" \
4444
"scipy==1.18.0"
45-
- name: Install pinned GFortran
45+
- name: Install pinned GFortran and LAPACK link dependencies
4646
shell: bash
4747
run: |
48+
packages=(libblas-dev liblapack-dev)
4849
if ! command -v "$PRIK_GFORTRAN_BINARY" >/dev/null 2>&1; then
49-
sudo apt-get update
50-
sudo apt-get install --yes "$PRIK_GFORTRAN_PACKAGE"
50+
packages+=("$PRIK_GFORTRAN_PACKAGE")
5151
fi
52+
sudo apt-get update
53+
sudo apt-get install --yes "${packages[@]}"
5254
compiler_dir="$RUNNER_TEMP/prik-gfortran"
5355
mkdir -p "$compiler_dir"
5456
ln -sf "$(command -v "$PRIK_GFORTRAN_BINARY")" "$compiler_dir/gfortran"

examples/lapack/README.md

Lines changed: 112 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ same Meson, Ninja, and SciPy versions used by the dedicated BLAS/LAPACK CI lane
3535
before running this example:
3636

3737
```console
38+
sudo apt-get install libblas-dev liblapack-dev
3839
python3 -m pip install "meson==1.11.2" "ninja==1.13.0" "scipy==1.18.0"
3940
```
4041

@@ -47,8 +48,9 @@ It performs these operations once:
4748
cached shared library;
4849
3. build one PRIK extension from the complete generated contract and shared
4950
library;
50-
4. build one raw f2py comparison surface for the reviewed 127 routines, linked
51-
to that same complete native library; and
51+
4. independently build one raw f2py comparison surface from the 125 reviewed
52+
f2py-compatible routine sources and their minimal module dependency, linking unselected
53+
LAPACK and BLAS helper symbols from the system development libraries; and
5254
5. reuse the three imported comparison surfaces for every correctness file.
5355

5456
The contract-generation command is:
@@ -63,28 +65,52 @@ f2py command assembled by `conftest.py` is equivalent to:
6365

6466
```console
6567
python3 -m numpy.f2py -c -m f2py_reference_lapack_example \
66-
<the 127 source files recorded in routine_inventory.py> \
67-
<the cached complete libprik_full_lapack.so> \
68+
examples/lapack/native/la_constants.f90 \
69+
<the 125 f2py-compatible source files recorded in routine_inventory.py> \
70+
only: <the 125 f2py-compatible routine names> : \
71+
--dep lapack --dep blas \
72+
--f2cmap /tmp/prik-lapack/.f2py_f2cmap \
6873
--build-dir /tmp/prik-lapack/f2py --f77flags=-O0 --f90flags=-O0 --opt=-O0
6974
```
7075

76+
`dlartg.f90` imports the `LA_CONSTANTS` module and declares its arguments as
77+
`REAL(wp)`. The f2py build therefore includes `la_constants.f90` as a build-only
78+
dependency and supplies a kind map that resolves `wp` to C `double`. The
79+
`only:` list makes the 125 f2py-compatible routine wrappers explicit; module support metadata
80+
is not counted as a selected routine. The dependency is compiled only to
81+
satisfy f2py's standalone selected-source build. f2py does not compile or link
82+
PRIK's complete 2,062-source native artifact. The reviewed implementations come
83+
from `examples/lapack/native/`; only their unselected transitive LAPACK and BLAS
84+
dependencies come from the system libraries.
85+
7186
Build products stay in pytest temporary/cache directories. Failures report the
7287
compiler identity, command, stdout, and stderr. Neither build dirties the
7388
repository.
7489

7590
The fixtures import the complete PRIK module, the raw f2py module, and
7691
`scipy.linalg.lapack`. Character flags are Python `str` through PRIK and
77-
`bytes` through SciPy/f2py. PRIK and raw f2py retain native argument ordering;
78-
SciPy projects arrays and optional arguments into its documented Python API.
92+
`bytes` through SciPy/f2py. PRIK retains the complete native argument order.
93+
Raw f2py retains the order of the arguments it exposes, but infers and hides
94+
leading dimensions and a few other shape-only scalar arguments; each visible
95+
f2py call follows the generated wrapper signature. SciPy projects arrays and
96+
optional arguments into its documented Python API.
7997

8098
The Reference LAPACK subroutines do not declare Fortran `intent`, so raw f2py
8199
does not project their scalar writebacks. It still exposes in-place array
82-
mutation, which provides enough output for the independent oracle in 114 of
83-
the 123 subroutines; the four LAPACK functions return their values normally. Nine
84-
subroutines have an essential scalar-only result, or need an unprojected scalar
85-
to validate their mutated vector, and are explicitly recorded in
86-
`F2PY_NUMERICAL_LIMITATIONS`. All 127 selected names must still be exported by
87-
the one f2py comparison module.
100+
mutation, which provides enough output for the independent oracle in 112 of
101+
the 121 exported subroutines; the four exported LAPACK functions return their
102+
values normally. Nine subroutines have an essential scalar-only result, or need
103+
an unprojected scalar to validate their mutated vector, and are explicitly
104+
recorded in `F2PY_NUMERICAL_LIMITATIONS`.
105+
106+
`dgees` and `dgges` remain in the 127-routine correctness inventory but are
107+
recorded in `F2PY_EXPORT_LIMITATIONS`. Their Reference LAPACK interfaces accept
108+
external selection callbacks. From the unannotated implementation sources,
109+
NumPy f2py 2.5.1 generates incomplete callback declarations (`select_t` and
110+
`selctg_t`) and invalid C wrapper code. Those two routines are therefore
111+
validated through PRIK, SciPy, and independent Schur reconstruction without a
112+
raw-f2py call; the remaining 125 selected names must be exported by the one
113+
f2py comparison module.
88114

89115
The nine reviewed limitations are:
90116

@@ -109,15 +135,28 @@ pivot ties may choose another valid permutation. Tests therefore use residuals,
109135
subspaces, factor reconstruction, and orthogonality instead of byte equality
110136
where the contract permits freedom.
111137

112-
All numerical inputs use `dtype=np.float64`. `assert_allclose_float64` scales
113-
tolerance with double-precision epsilon and operation length. Residual checks
114-
also scale by matrix and solution norms. Integer results, `INFO`, shapes, dtypes,
115-
pivot bounds, and untouched sentinel storage use exact comparisons.
138+
All numerical arrays use `dtype=np.float64`. PRIK exposes the native scalar
139+
contract exactly, so its visible calls use `np.int32(...)` for LAPACK `INTEGER`
140+
arguments, `np.float64(...)` for scalar `DOUBLE PRECISION` arguments, and
141+
`np.bool_(...)` for scalar `LOGICAL` arguments. SciPy and f2py calls retain their
142+
own accepted Python conventions beside the PRIK call. `assert_allclose_float64`
143+
scales tolerance with double-precision epsilon and operation length. Residual
144+
checks also scale by matrix and solution norms. Integer results, `INFO`, shapes,
145+
dtypes, pivot bounds, and untouched sentinel storage use exact comparisons.
146+
147+
`DTGSEN` and `DTRSEN` accept default-Fortran `LOGICAL` selection arrays. The
148+
pinned GFortran ABI stores each such element in four bytes, while PRIK's Python
149+
surface accepts a NumPy bool buffer. `gfortran_logical_mask` makes that genuine
150+
ABI difference explicit by placing each truth byte at the start of its native
151+
four-byte cell. The two affected calls remain visible, independently validate
152+
the requested reorder, and are recorded in `PRIK_ABI_ADAPTERS` rather than
153+
silently treating an ordinary two-byte NumPy mask as native storage.
116154

117155
## Arrays, storage, workspaces, and indexes
118156

119157
Matrices are explicitly Fortran contiguous where the native contract requires
120-
column-major storage. Leading dimensions remain visible in PRIK/f2py calls.
158+
column-major storage. Leading dimensions remain visible in PRIK calls and are
159+
omitted only where the generated f2py signature explicitly infers them.
121160
Tests use NaNs or sentinel values in unused triangles and padding, then prove
122161
that those locations remain untouched. Helpers reconstruct general band,
123162
symmetric band, tridiagonal, packed, triangular, and rectangular-full-packed
@@ -130,9 +169,11 @@ Invalid argument calls that could route through `XERBLA` are not made in-process
130169

131170
Native LAPACK pivots and positions are one-based. PRIK and the raw f2py surface
132171
preserve native values. SciPy converts several low-level pivot/index APIs to
133-
zero-based values; each affected test performs the conversion beside the call.
134-
The inventory marks every pivot/index-bearing routine so a new conversion
135-
cannot be hidden in a generic adapter.
172+
zero-based values, but preserves one-based `JPVT` for `DGELSY`/`DGEQP3`, one-based
173+
`IPIV` for the general-tridiagonal `DGTTRF`/`DGTTRS` family, and one-based Schur
174+
reorder positions for `DTGEXC`/`DTREXC`. Each affected test keeps the observed
175+
convention beside the call. The inventory marks every pivot/index-bearing
176+
routine so a new conversion cannot be hidden in a generic adapter.
136177

137178
## Run and diagnose
138179

@@ -173,9 +214,11 @@ documented totals.
173214
| Explicit correctness tests | 127 |
174215
| PRIK exports required in CI | 127 |
175216
| SciPy exports used | 127 |
176-
| f2py exports required in CI | 127 |
177-
| Routines satisfying the independent oracle through f2py | 118 |
217+
| f2py exports required in CI | 125 |
218+
| Routines satisfying the independent oracle through f2py | 116 |
219+
| Documented raw-f2py export limitations | 2 |
178220
| Documented raw-f2py numerical projection limitations | 9 |
221+
| Documented PRIK default-LOGICAL ABI adapters | 2 |
179222
| Documented unsupported/skipped routines | 0 |
180223

181224
Runtime success, f2py projection limitations, and any PRIK failures are CI
@@ -200,8 +243,10 @@ def test_dgesv_solves_general_system(prik_lapack, scipy_lapack, f2py_lapack):
200243
prik_piv = np.empty(2, dtype=np.int32)
201244
f2py_piv = np.empty(2, dtype=np.int32)
202245

203-
prik_scalars = prik_lapack.dgesv(2, 1, prik_a, 2, prik_piv, prik_b, 2, 0)
204-
f2py_result = f2py_lapack.dgesv(2, 1, f2py_a, 2, f2py_piv, f2py_b, 2, 0)
246+
prik_scalars = prik_lapack.dgesv(
247+
np.int32(2), np.int32(1), prik_a, np.int32(2), prik_piv, prik_b, np.int32(2), np.int32(0)
248+
)
249+
f2py_result = f2py_lapack.dgesv(2, 1, f2py_a, f2py_piv, f2py_b, 0)
205250
scipy_lu, scipy_piv, scipy_x, scipy_info = scipy_lapack.dgesv(
206251
original_a.copy(order="F"), original_b.copy(order="F")
207252
)
@@ -234,8 +279,8 @@ def test_dgetrf_reconstructs_pivoted_lu(prik_lapack, scipy_lapack, f2py_lapack):
234279
prik_piv = np.empty(2, dtype=np.int32)
235280
f2py_piv = np.empty(2, dtype=np.int32)
236281

237-
prik_scalars = prik_lapack.dgetrf(2, 2, prik_a, 2, prik_piv, 0)
238-
f2py_result = f2py_lapack.dgetrf(2, 2, f2py_a, 2, f2py_piv, 0)
282+
prik_scalars = prik_lapack.dgetrf(np.int32(2), np.int32(2), prik_a, np.int32(2), prik_piv, np.int32(0))
283+
f2py_result = f2py_lapack.dgetrf(2, 2, f2py_a, f2py_piv, 0)
239284
scipy_lu, scipy_piv, scipy_info = scipy_lapack.dgetrf(original.copy(order="F"))
240285

241286
assert prik_scalars == (2, 2, 2, 0)
@@ -260,8 +305,8 @@ def test_dpotrf_reconstructs_spd_matrix(prik_lapack, scipy_lapack, f2py_lapack):
260305
stored = np.array([[4.0, np.nan], [1.0, 3.0]], dtype=np.float64, order="F")
261306
prik_a, f2py_a = column_major(stored), column_major(stored)
262307

263-
prik_scalars = prik_lapack.dpotrf("L", 2, prik_a, 2, 0)
264-
f2py_result = f2py_lapack.dpotrf(b"L", 2, f2py_a, 2, 0)
308+
prik_scalars = prik_lapack.dpotrf("L", np.int32(2), prik_a, np.int32(2), np.int32(0))
309+
f2py_result = f2py_lapack.dpotrf(b"L", 2, f2py_a, 0)
265310
scipy_factor, scipy_info = scipy_lapack.dpotrf(stored.copy(order="F"), lower=1, clean=0)
266311

267312
assert prik_scalars == (2, 2, 0)
@@ -289,8 +334,10 @@ def test_dgeqrf_reconstructs_qr_factorization(prik_lapack, scipy_lapack, f2py_la
289334
prik_tau = np.empty(2, dtype=np.float64)
290335
f2py_tau = np.empty(2, dtype=np.float64)
291336

292-
prik_scalars = prik_lapack.dgeqrf(3, 2, prik_a, 3, prik_tau, np.empty(16), 16, 0)
293-
f2py_result = f2py_lapack.dgeqrf(3, 2, f2py_a, 3, f2py_tau, np.empty(16), 16, 0)
337+
prik_scalars = prik_lapack.dgeqrf(
338+
np.int32(3), np.int32(2), prik_a, np.int32(3), prik_tau, np.empty(16), np.int32(16), np.int32(0)
339+
)
340+
f2py_result = f2py_lapack.dgeqrf(3, 2, f2py_a, f2py_tau, np.empty(16), 16, 0)
294341
scipy_qr, scipy_tau, _scipy_work, scipy_info = scipy_lapack.dgeqrf(matrix.copy(order="F"), lwork=16)
295342

296343
assert prik_scalars == (3, 2, 3, 16, 0)
@@ -318,8 +365,10 @@ def test_dsyev_returns_orthonormal_eigenvectors(prik_lapack, scipy_lapack, f2py_
318365
prik_w = np.empty(2, dtype=np.float64)
319366
f2py_w = np.empty(2, dtype=np.float64)
320367

321-
prik_scalars = prik_lapack.dsyev("V", "U", 2, prik_vectors, 2, prik_w, np.empty(16), 16, 0)
322-
f2py_result = f2py_lapack.dsyev(b"V", b"U", 2, f2py_vectors, 2, f2py_w, np.empty(16), 16, 0)
368+
prik_scalars = prik_lapack.dsyev(
369+
"V", "U", np.int32(2), prik_vectors, np.int32(2), prik_w, np.empty(16), np.int32(16), np.int32(0)
370+
)
371+
f2py_result = f2py_lapack.dsyev(b"V", b"U", 2, f2py_vectors, f2py_w, np.empty(16), 16, 0)
323372
scipy_w, scipy_vectors, scipy_info = scipy_lapack.dsyev(matrix.copy(order="F"), compute_v=1, lower=0, lwork=16)
324373

325374
assert prik_scalars == (2, 2, 16, 0)
@@ -346,8 +395,23 @@ def test_dgesvd_reconstructs_matrix(prik_lapack, scipy_lapack, f2py_lapack):
346395
prik_u, f2py_u = column_major(np.zeros((3, 3))), column_major(np.zeros((3, 3)))
347396
prik_vt, f2py_vt = column_major(np.zeros((2, 2))), column_major(np.zeros((2, 2)))
348397

349-
prik_scalars = prik_lapack.dgesvd("A", "A", 3, 2, prik_a, 3, prik_s, prik_u, 3, prik_vt, 2, np.empty(32), 32, 0)
350-
f2py_result = f2py_lapack.dgesvd(b"A", b"A", 3, 2, f2py_a, 3, f2py_s, f2py_u, 3, f2py_vt, 2, np.empty(32), 32, 0)
398+
prik_scalars = prik_lapack.dgesvd(
399+
"A",
400+
"A",
401+
np.int32(3),
402+
np.int32(2),
403+
prik_a,
404+
np.int32(3),
405+
prik_s,
406+
prik_u,
407+
np.int32(3),
408+
prik_vt,
409+
np.int32(2),
410+
np.empty(32),
411+
np.int32(32),
412+
np.int32(0),
413+
)
414+
f2py_result = f2py_lapack.dgesvd(b"A", b"A", 3, 2, f2py_a, f2py_s, f2py_u, f2py_vt, np.empty(32), 32, 0)
351415
scipy_u, scipy_s, scipy_vt, scipy_info = scipy_lapack.dgesvd(
352416
matrix.copy(order="F"), compute_uv=1, full_matrices=1, lwork=32
353417
)
@@ -379,8 +443,10 @@ def test_dtbtrs_solves_triangular_band_system(prik_lapack, scipy_lapack, f2py_la
379443
expected = np.array([[1.0], [2.0]], dtype=np.float64)
380444
prik_b, f2py_b = rhs.copy(order="F"), rhs.copy(order="F")
381445

382-
prik_scalars = prik_lapack.dtbtrs("U", "N", "N", 2, 1, 1, band, 2, prik_b, 2, 0)
383-
f2py_result = f2py_lapack.dtbtrs(b"U", b"N", b"N", 2, 1, 1, band, 2, f2py_b, 2, 0)
446+
prik_scalars = prik_lapack.dtbtrs(
447+
"U", "N", "N", np.int32(2), np.int32(1), np.int32(1), band, np.int32(2), prik_b, np.int32(2), np.int32(0)
448+
)
449+
f2py_result = f2py_lapack.dtbtrs(b"U", b"N", b"N", 2, 1, 1, band, f2py_b, 0)
384450
scipy_x, scipy_info = scipy_lapack.dtbtrs(band, rhs.copy(order="F"), uplo=b"U", trans=b"N", diag=b"N")
385451

386452
assert f2py_result is None
@@ -400,10 +466,18 @@ def test_dgecon_estimates_reciprocal_condition(prik_lapack, scipy_lapack, f2py_l
400466
expected = 1.0
401467

402468
prik_scalars = prik_lapack.dgecon(
403-
"1", 1, factor.copy(order="F"), 1, 4.0, 0.0, np.empty(4), np.empty(1, dtype=np.int32), 0
469+
"1",
470+
np.int32(1),
471+
factor.copy(order="F"),
472+
np.int32(1),
473+
np.float64(4.0),
474+
np.float64(0.0),
475+
np.empty(4),
476+
np.empty(1, dtype=np.int32),
477+
np.int32(0),
404478
)
405479
f2py_result = f2py_lapack.dgecon(
406-
b"1", 1, factor.copy(order="F"), 1, 4.0, 0.0, np.empty(4), np.empty(1, dtype=np.int32), 0
480+
b"1", 1, factor.copy(order="F"), 4.0, 0.0, np.empty(4), np.empty(1, dtype=np.int32), 0
407481
)
408482
scipy_rcond, scipy_info = scipy_lapack.dgecon(factor.copy(order="F"), 4.0, norm=b"1")
409483

examples/lapack/conftest.py

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
NATIVE_ROOT = EXAMPLE_ROOT / "native"
2222
BUILD_FLAGS = "-O0"
2323
FORTRAN_SUFFIXES = (".f", ".f90", ".f95", ".f03", ".f08", ".for", ".f77", ".ftn")
24+
F2PY_BUILD_DEPENDENCIES = ("la_constants.f90",)
25+
F2PY_KIND_MAP = "{'real': {'wp': 'double'}}\n"
26+
F2PY_LINK_DEPENDENCIES = ("lapack", "blas")
2427

2528

2629
@dataclass(frozen=True)
@@ -91,6 +94,45 @@ def _selected_source(routine: str) -> Path:
9194
return matches[0]
9295

9396

97+
def _f2py_source_plan() -> tuple[Path, ...]:
98+
"""Return reviewed implementations plus their minimal compile dependency."""
99+
dependencies = tuple(NATIVE_ROOT / name for name in F2PY_BUILD_DEPENDENCIES)
100+
missing_dependencies = [str(path) for path in dependencies if not path.is_file()]
101+
if missing_dependencies:
102+
pytest.fail(f"missing f2py build dependencies: {missing_dependencies}")
103+
selected = tuple(_selected_source(name) for name in ROUTINES if name not in F2PY_EXPORT_LIMITATIONS)
104+
return dependencies + selected
105+
106+
107+
def _f2py_build_command(workdir: Path) -> tuple[str, ...]:
108+
"""Build only reviewed implementations and link external helper symbols."""
109+
module_name = "f2py_reference_lapack_example"
110+
f2cmap = workdir / ".f2py_f2cmap"
111+
f2cmap.write_text(F2PY_KIND_MAP, encoding="utf-8")
112+
selected_routines = tuple(name for name in ROUTINES if name not in F2PY_EXPORT_LIMITATIONS)
113+
link_dependencies = tuple(item for dependency in F2PY_LINK_DEPENDENCIES for item in ("--dep", dependency))
114+
return (
115+
sys.executable,
116+
"-m",
117+
"numpy.f2py",
118+
"-c",
119+
"-m",
120+
module_name,
121+
*(str(source) for source in _f2py_source_plan()),
122+
"only:",
123+
*selected_routines,
124+
":",
125+
*link_dependencies,
126+
"--f2cmap",
127+
str(f2cmap),
128+
"--build-dir",
129+
str(workdir / "generated"),
130+
f"--f77flags={BUILD_FLAGS}",
131+
f"--f90flags={BUILD_FLAGS}",
132+
f"--opt={BUILD_FLAGS}",
133+
)
134+
135+
94136
@pytest.fixture(scope="session")
95137
def prik_build(tmp_path_factory: pytest.TempPathFactory) -> BuiltLapack:
96138
"""Build the one complete PRIK LAPACK wrapper through the established path."""
@@ -134,27 +176,11 @@ def prik_lapack(prik_build: BuiltLapack):
134176

135177
@pytest.fixture(scope="session")
136178
def f2py_build(tmp_path_factory: pytest.TempPathFactory) -> BuiltLapack:
137-
"""Build one raw f2py comparison surface linked to the complete library."""
179+
"""Build one raw f2py surface from only the reviewed implementations."""
138180
compiler = full._compiler()
139181
workdir = tmp_path_factory.mktemp("f2py-reference-lapack-example")
140-
shared = full._cached_native_shared_library("lapack")
141182
module_name = "f2py_reference_lapack_example"
142-
selected_sources = tuple(_selected_source(name) for name in ROUTINES if name not in F2PY_EXPORT_LIMITATIONS)
143-
command = (
144-
sys.executable,
145-
"-m",
146-
"numpy.f2py",
147-
"-c",
148-
"-m",
149-
module_name,
150-
*(str(source) for source in selected_sources),
151-
str(shared),
152-
"--build-dir",
153-
str(workdir / "generated"),
154-
f"--f77flags={BUILD_FLAGS}",
155-
f"--f90flags={BUILD_FLAGS}",
156-
f"--opt={BUILD_FLAGS}",
157-
)
183+
command = _f2py_build_command(workdir)
158184
result = _run_build(command, workdir, compiler)
159185
return BuiltLapack(
160186
module=_import_built_module(module_name, workdir),

examples/lapack/helpers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def native_pivots(scipy_pivots: np.ndarray) -> np.ndarray:
5353
return np.asarray(scipy_pivots, dtype=np.int32) + np.int32(1)
5454

5555

56+
def gfortran_logical_mask(values) -> np.ndarray:
57+
"""Represent a default-GFortran LOGICAL vector through PRIK's bool buffer ABI."""
58+
logical_bytes = np.zeros(len(values) * np.dtype(np.int32).itemsize, dtype=np.bool_)
59+
logical_bytes[:: np.dtype(np.int32).itemsize] = np.asarray(values, dtype=np.bool_)
60+
return logical_bytes
61+
62+
5663
def pivot_matrix(pivots: np.ndarray, size: int, *, one_based: bool) -> np.ndarray:
5764
"""Build the row permutation represented by sequential LAPACK pivots."""
5865
permutation = np.eye(size, dtype=np.float64)

0 commit comments

Comments
 (0)