Design a Pythonic BLAS API¶
This tutorial turns four Reference BLAS routines into a small Python API:
from prik_linalg import DenseMatrix, dot, matmul, matvec, norm
The native sources stay unchanged. The runnable example contains four working files:
examples/fortran/pythonic_blas/
├── _prik_linalg_native.pyi # edited native contract
├── prik_linalg.py # public Python API
├── build.sh # builds the extension
└── test_pythonic_blas.py # checks the result with NumPy
1. Choose the native operations¶
| BLAS routine | Python operation |
|---|---|
DDOT |
dot(x, y) |
DNRM2 |
norm(x) |
DGEMV |
matvec(matrix, vector) |
DGEMM |
matmul(left, right) |
For example, DGEMV expects eleven native arguments:
SUBROUTINE DGEMV(TRANS,M,N,ALPHA,A,LDA,X,INCX,BETA,Y,INCY)
DOUBLE PRECISION ALPHA,BETA
INTEGER INCX,INCY,LDA,M,N
CHARACTER TRANS
DOUBLE PRECISION A(LDA,*),X(*),Y(*)
A Python caller should provide only the matrix and vector. The transposition mode, dimensions, increments, product scalars and output storage are implementation details.
2. Inspect the generated contract¶
Run generate --pyi without --out to print the starting contract instead of
creating another file:
python3 -m prik generate --pyi \
examples/fortran/blas/native/ddot.f \
examples/fortran/blas/native/dnrm2.f90 \
examples/fortran/blas/native/dgemv.f \
examples/fortran/blas/native/dgemm.f
The generated declarations follow the native signatures. Use them as the
starting point for the one edited contract,
_prik_linalg_native.pyi.
3. Edit the .pyi contract¶
The edited DGEMV declaration is:
@bind("DGEMV")
@standalone
@native_call([
String[1]("N"), Int32(Arg(0).shape[0]), Int32(Arg(0).shape[1]),
Float64(1.0), Arg(0), Int32(Arg(0).shape[0]),
Arg(1), Int32(1), Float64(0.0),
Return("y", 0), Int32(1),
])
def matvec(
matrix: Float64[:, :],
vector: Float64[matrix.shape[1]],
) -> Float64[matrix.shape[0]]: ...
Eleven native arguments become two, and this one declaration does the whole redesign:
| Contract edit | Result |
|---|---|
@bind("DGEMV") |
Renames the Python operation without changing the native symbol. |
@native_call([...]) |
Defines the exact BLAS argument order. |
String[1]("N") |
Declares the transposition mode, so TRANS never reaches Python. |
| Typed shape projections | Read M, N and LDA from the NumPy shape as the default Fortran INTEGER the dummies declare. |
Int32(1) |
Fixes both vector increments to one. |
Float64(1.0) and Float64(0.0) |
Selects an ordinary matrix product. |
Return("y", 0) |
Allocates and returns the output vector. |
| Array dimensions | Reject incompatible shapes before BLAS runs. |
Float64[:, :] |
Uses the default Fortran layout, which BLAS consumes directly. |
Two entries deserve a note. Wrapping the extent in Int32(...) is what lets it
stay hidden: a bare Arg(0).shape[0] is materialized as size_t, the right
identity for a C size_t parameter but not for the four-byte INTEGER BLAS
declares. And String[1]("N") is a declaration, not a conversion — it states
the character the native parameter receives, and it crosses as an
interoperable char that a character(len=1) dummy takes directly.
DDOT, DNRM2 and DGEMM use the same mechanisms. Their complete
declarations are in the same .pyi file.
The .pyi format defines these entries, Calls
and Results explains native
argument mappings, and Arrays covers shapes and layout.
4. Add the small Python API¶
The contract now owns every native fact, so all four operations are the
extension's own functions. prik_linalg.py
re-exports them and adds one convenience class:
import numpy as np
from _prik_linalg_native import dot, matmul, matvec, norm
class DenseMatrix:
"""Hold one float64 matrix and forward to the functional API."""
def __init__(self, values):
self.values = np.asfortranarray(values)
def dot(self, other):
return matmul(self.values, other) if other.ndim == 2 else matvec(self.values, other)
def __matmul__(self, other):
return self.dot(other)
There is no native argument translation left to do. Native ordering, extents,
leading dimensions, transposition modes, numeric constants, validation and
output allocation all live in the contract. DenseMatrix adds one Python
convenience: it converts its matrix to Fortran order once when constructed, so
repeated operations pass that storage directly to BLAS.
5. Build it¶
From the repository root, source the single build script:
source examples/fortran/pythonic_blas/build.sh
It builds the private _prik_linalg_native extension from the edited contract
and Reference BLAS sources, then adds the extension and prik_linalg.py to
PYTHONPATH.
6. Use the API¶
import numpy as np
from prik_linalg import DenseMatrix, dot, matmul, matvec, norm
x = np.array([1.0, 2.0, 3.0], dtype=np.float64)
y = np.array([4.0, 5.0, 6.0], dtype=np.float64)
matrix = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64)
matrix_f = np.asfortranarray(matrix)
print(dot(x, y))
print(norm(x))
print(matvec(matrix_f, x[:2]))
print(matmul(matrix_f, matrix_f))
A = DenseMatrix(matrix)
print(A @ x[:2])
Result:
32.0
3.7416573867739413
[ 5. 11.]
[[ 7. 10.]
[15. 22.]]
[ 5. 11.]
There are no public dimensions, increments, leading dimensions, mode characters, product scalars or output buffers.
7. Test it¶
The one test file compares every operation with NumPy and also checks shape,
dtype, rank, layout, result allocation, unchanged inputs, the public signatures,
the one-time DenseMatrix layout conversion and method forwarding:
python3 -m pytest -q examples/fortran/pythonic_blas/test_pythonic_blas.py
The final API is small even though the underlying routines are not:
dot(x, y)
norm(x)
matvec(matrix_f, vector)
matmul(left_f, right_f)
A = DenseMatrix(matrix)
A.dot(vector)
A @ vector
Next, see the complete BLAS wrapper for
the full 155-routine surface or Editing .pyi
Contracts for other API transformations.