Call fortran from Python#

%matplotlib inline
%config InlineBackend.figure_format = 'retina'
import matplotlib.pyplot as plt
import scipy.fftpack as sf
import scipy.linalg as sl
import numpy as np
import warnings
warnings.filterwarnings('ignore')

f2py#

f2py is a part of Numpy and there are three ways to wrap Fortran with Python :

  • Write some fortran subroutines and just run f2py to create Python modules.

  • Insert special f2py directives inside Fortran source for complex wrapping.

  • Write a interface file (.pyf) to wrap Fortran files without changing them. f2py automatically generate the pyf template file that can be modified.

Simple Fortran subroutine to compute norm#

Fortran 90/95 free format#

%%file euclidian_norm.f90
subroutine euclidian_norm (a, b, c)
  real(8), intent(in) :: a, b
  real(8), intent(out) :: c 
  c =	sqrt (a*a+b*b) 
end subroutine euclidian_norm
Writing euclidian_norm.f90

Fortran 77 fixed format#

%%file euclidian_norm.f
      subroutine euclidian_norm (a, b, c)
      real*8 a,b,c
Cf2py intent(out) c
      c = sqrt (a*a+b*b) 
      end 
Writing euclidian_norm.f

Build extension module with f2py program#

import sys
!{sys.executable} -m numpy.f2py --quiet -c euclidian_norm.f90 -m vect  --fcompiler=gnu95 --f90flags=-O3
/usr/share/miniconda3/envs/runenv/lib/python3.10/site-packages/setuptools/command/install.py:34: SetuptoolsDeprecationWarning: setup.py install is deprecated. Use build and pip and other standards-based tools.
  warnings.warn(

Use the extension module in Python#

import vect
c = vect.euclidian_norm(3,4)
c
5.0
print(vect.euclidian_norm.__doc__) # Docstring is automatically generate
c = euclidian_norm(a,b)

Wrapper for ``euclidian_norm``.

Parameters
----------
a : input float
b : input float

Returns
-------
c : float

Fortran magic#

  • Jupyter extension that help to use fortran code in an interactive session.

  • It adds a %%fortran cell magic that compile and import the Fortran code in the cell, using F2py.

  • The contents of the cell are written to a .f90 file in the directory IPYTHONDIR/fortran using a filename with the hash of the code. This file is then compiled. The resulting module is imported and all of its symbols are injected into the user’s namespace.

Documentation

%load_ext fortranmagic
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
Cell In[7], line 1
----> 1 get_ipython().run_line_magic('load_ext', 'fortranmagic')

File /usr/share/miniconda3/envs/runenv/lib/python3.10/site-packages/IPython/core/interactiveshell.py:2369, in InteractiveShell.run_line_magic(self, magic_name, line, _stack_depth)
   2367     kwargs['local_ns'] = self.get_local_scope(stack_depth)
   2368 with self.builtin_trap:
-> 2369     result = fn(*args, **kwargs)
   2370 return result

File /usr/share/miniconda3/envs/runenv/lib/python3.10/site-packages/IPython/core/magics/extension.py:33, in ExtensionMagics.load_ext(self, module_str)
     31 if not module_str:
     32     raise UsageError('Missing module name.')
---> 33 res = self.shell.extension_manager.load_extension(module_str)
     35 if res == 'already loaded':
     36     print("The %s extension is already loaded. To reload it, use:" % module_str)

File /usr/share/miniconda3/envs/runenv/lib/python3.10/site-packages/IPython/core/extensions.py:76, in ExtensionManager.load_extension(self, module_str)
     69 """Load an IPython extension by its module name.
     70 
     71 Returns the string "already loaded" if the extension is already loaded,
     72 "no load function" if the module doesn't have a load_ipython_extension
     73 function, or None if it succeeded.
     74 """
     75 try:
---> 76     return self._load_extension(module_str)
     77 except ModuleNotFoundError:
     78     if module_str in BUILTINS_EXTS:

File /usr/share/miniconda3/envs/runenv/lib/python3.10/site-packages/IPython/core/extensions.py:91, in ExtensionManager._load_extension(self, module_str)
     89 with self.shell.builtin_trap:
     90     if module_str not in sys.modules:
---> 91         mod = import_module(module_str)
     92     mod = sys.modules[module_str]
     93     if self._call_load_ipython_extension(mod):

File /usr/share/miniconda3/envs/runenv/lib/python3.10/importlib/__init__.py:126, in import_module(name, package)
    124             break
    125         level += 1
--> 126 return _bootstrap._gcd_import(name[level:], package, level)

File <frozen importlib._bootstrap>:1050, in _gcd_import(name, package, level)

File <frozen importlib._bootstrap>:1027, in _find_and_load(name, import_)

File <frozen importlib._bootstrap>:1006, in _find_and_load_unlocked(name, import_)

File <frozen importlib._bootstrap>:688, in _load_unlocked(spec)

File <frozen importlib._bootstrap_external>:883, in exec_module(self, module)

File <frozen importlib._bootstrap>:241, in _call_with_frames_removed(f, *args, **kwds)

File /usr/share/miniconda3/envs/runenv/lib/python3.10/site-packages/fortranmagic.py:36
     34 from IPython.utils import py3compat
     35 from IPython.utils.io import capture_output
---> 36 from IPython.utils.path import get_ipython_cache_dir
     37 import numpy as np
     38 from numpy.f2py import f2py2e

ImportError: cannot import name 'get_ipython_cache_dir' from 'IPython.utils.path' (/usr/share/miniconda3/envs/runenv/lib/python3.10/site-packages/IPython/utils/path.py)

F2py directives#

  • F2PY introduces also some extensions to Fortran 90/95 language specification that help designing Fortran to Python interface, make it more “Pythonic”.

  • If editing Fortran codes is acceptable, these specific attributes can be inserted directly to Fortran source codes. Special comment lines are ignored by Fortran compilers but F2PY interprets them as normal lines.

%%fortran 
subroutine euclidian_norm(a,c,n) 
  integer :: n 
  real(8),dimension(n),intent(in) :: a
  !f2py optional , depend(a) :: n=len(a)
  real(8),intent(out) :: c 
  real(8) :: sommec 
  integer :: i
  sommec = 0 
  do i=1,n
    sommec=sommec+a( i )*a( i ) 
  end do
  c = sqrt (sommec) 
end subroutine euclidian_norm
a=[2,3,4]  # Python list
type(a)
list
euclidian_norm(a)
5.385164807134504
a=np.arange(2,5)  # numpy array
type(a)
numpy.ndarray
euclidian_norm(a)
5.385164807134504
print(euclidian_norm.__doc__) # Documentation
c = euclidian_norm(a,[n])

Wrapper for ``euclidian_norm``.

Parameters
----------
a : input rank-1 array('d') with bounds (n)

Other Parameters
----------------
n : input int, optional
    Default: len(a)

Returns
-------
c : float

F2py directives#

  • optional: The corresponding argument is moved to the end.

  • required: This is default. Use it to disable automatic optional setting.

  • intent(in | inout | out | hide) , intent(in) is the default.

  • intent(out) is implicitly translated to intent(out,hide) .

  • intent(copy) and intent(overwrite) control changes for input arguments.

  • check performs some assertions, it is often automatically generated.

  • depend: f2py detects cyclic dependencies.

  • allocatable, parameter

  • intent(callback), external: for function as arguments.

  • intent(c) C-type argument , array or function.

  • C expressions: rank, shape, len, size, slen.

Callback#

You can call a python function inside your fortran code

%%fortran
subroutine sum_f (f ,n, s) 
  !Compute sum(f(i), i=1,n) 
  external f 
  integer, intent(in) :: n 
  real, intent(out) :: s
  s = 0.0 
  do i=1,n
    s=s+f(i)
  end do 
end subroutine sum_f
def fonction(i) : # python function
    return i*i

sum_f(fonction,3) 
14.0
sum_f(lambda x :x**2,3) # lambda function
14.0

Fortran arrays and Numpy arrays#

Let’s see how to pass numpy arrays to fortran subroutine.

%%fortran --extra "-DF2PY_REPORT_ON_ARRAY_COPY=1"
subroutine push( positions, velocities, dt, n)
  integer, intent(in) :: n
  real(8), intent(in) :: dt
  real(8), dimension(n,3), intent(in) :: velocities
  real(8), dimension(n,3) :: positions
  do i = 1, n
    positions(i,:) = positions(i,:) + dt*velocities(i,:)
  end do
end subroutine push
positions = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
velocities = [[0, 1, 2], [0, 3, 2], [0, 1, 3]]
import sys
push(positions, velocities, 0.1)
positions # memory is not updated because we used C memory storage
[[0, 0, 0], [0, 0, 0], [0, 0, 0]]

During execution, the message “created an array from object” is displayed, because a copy of is made when passing multidimensional array to fortran subroutine.

positions = np.array(positions, dtype='f8', order='F')
push(positions, velocities, 0.1)
positions # the memory is updated
array([[0. , 0.1, 0.2],
       [0. , 0.3, 0.2],
       [0. , 0.1, 0.3]])

Signature file#

This file contains descriptions of wrappers to Fortran or C functions, also called as signatures of the functions. F2PY can create initial signature file by scanning Fortran source codes and catching all relevant information needed to create wrapper functions.

f2py vector.f90 -h vector.pyf
  • vector.pyf

!    -*- f90 -*-
! Note: the context of this file is case sensitive.

subroutine euclidian_norm(a,c,n) ! in vector.f90
    real(kind=8) dimension(n),intent(in) :: a
    real(kind=8) intent(out) :: c
    integer optional,check(len(a)>=n),depend(a) :: n=len(a)
end subroutine euclidian_norm

! This file was auto-generated with f2py (version:2).
! See http://cens.ioc.ee/projects/f2py2e/

Wrap lapack function dgemm with f2py#

  • Generate the signature file

%rm -f dgemm.f dgemm.pyf
!wget -q http://ftp.mcs.anl.gov/pub/MINPACK-2/blas/dgemm.f
!{sys.executable} -m numpy.f2py -m mylapack --overwrite-signature -h dgemm.pyf dgemm.f
Reading fortran codes...
	Reading file 'dgemm.f' (format:fix,strict)
rmbadname1: Replacing "max" with "max_bn".
Post-processing...
	Block: mylapack
			Block: dgemm
Post-processing (stage 2)...
Saving signatures to file "./dgemm.pyf"
!    -*- f90 -*-
! Note: the context of this file is case sensitive.

python module mylapack ! in 
    interface  ! in :mylapack
        subroutine dgemm(transa,transb,m,n,k,alpha,a,lda,b,ldb,beta,c,ldc) ! in :mylapack:dgemm.f
            character*1 :: transa
            character*1 :: transb
            integer :: m
            integer :: n
            integer :: k
            double precision :: alpha
            double precision dimension(lda,*) :: a
            integer, optional,check(shape(a,0)==lda),depend(a) :: lda=shape(a,0)
            double precision dimension(ldb,*) :: b
            integer, optional,check(shape(b,0)==ldb),depend(b) :: ldb=shape(b,0)
            double precision :: beta
            double precision dimension(ldc,*) :: c
            integer, optional,check(shape(c,0)==ldc),depend(c) :: ldc=shape(c,0)
        end subroutine dgemm
    end interface 
end python module mylapack

! This file was auto-generated with f2py (version:2).
! See http://cens.ioc.ee/projects/f2py2e/
!{sys.executable} -m numpy.f2py --quiet -c dgemm.pyf -llapack
import numpy as np
import mylapack
a = np.array([[7,8],[3,4],[1,2]])
b = np.array([[1,2,3],[4,5,6]])
print("a=",a) 
print("b=",b)
assert a.shape[1] == b.shape[0]
c = np.zeros((a.shape[0],b.shape[1]),'d',order='F')
mylapack.dgemm('N','N',a.shape[0],b.shape[1],a.shape[1],1.0,a,b,1.0,c)
print(c)
np.all(c == a @ b) # check with numpy matrix multiplication 
a= [[7 8]
 [3 4]
 [1 2]]
b= [[1 2 3]
 [4 5 6]]
[[39. 54. 69.]
 [19. 26. 33.]
 [ 9. 12. 15.]]
True

Exercise#

  • Modify the file dgemm.pyf to set all arguments top optional and keep only the two matrices as input.

# %load solutions/fortran/dgemm2.pyf
  • Build the python module

!{sys.executable} -m numpy.f2py --quiet -c solutions/fortran/dgemm2.pyf -llapack --f90flags=-O3
Traceback (most recent call last):
  File "/usr/local/Cellar/python@3.8/3.8.5/Frameworks/Python.framework/Versions/3.8/lib/python3.8/runpy.py", line 194, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/local/Cellar/python@3.8/3.8.5/Frameworks/Python.framework/Versions/3.8/lib/python3.8/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/usr/local/lib/python3.8/site-packages/numpy/f2py/__main__.py", line 4, in <module>
    main()
  File "/usr/local/lib/python3.8/site-packages/numpy/f2py/f2py2e.py", line 692, in main
    run_compile()
  File "/usr/local/lib/python3.8/site-packages/numpy/f2py/f2py2e.py", line 603, in run_compile
    modulename = get_f2py_modulename(f)
  File "/usr/local/lib/python3.8/site-packages/numpy/distutils/command/build_src.py", line 763, in get_f2py_modulename
    with open(source) as f:
FileNotFoundError: [Errno 2] No such file or directory: 'solutions/fortran/dgemm2.pyf'
import mylapack2
a = np.array([[7,8],[3,4],[1,2]])
b = np.array([[1,2,3],[4,5,6]])
c = mylapack2.dgemm(a,b)
np.all( c == a @ b)

Check performance between numpy and mylapack#

a = np.random.random((512,128))
b = np.random.random((128,512))
%timeit c = mylapack2.dgemm(a,b)
%timeit c = a @ b

Fortran arrays allocated in a subroutine share same memory in Python

%%fortran
module f90module
   implicit none
   real(8), dimension(:),   allocatable :: farray
contains
   subroutine init( n ) !Allocation du tableau farray
   integer, intent(in) :: n
   allocate(farray(n))
   end subroutine init
end module f90module
f90module.init(10)
len(f90module.farray)

Numpy arrays allocated in Python passed to Fortran are already allocated

%%fortran
module f90module
   implicit none
   real(8), dimension(:),   allocatable :: farray
contains
   subroutine test_array( allocated_flag, array_size )
   logical, intent(out) :: allocated_flag
   integer, intent(out) :: array_size
   allocated_flag = allocated(farray)
   array_size = size(farray)
   end subroutine test_array
end module f90module
f90module.farray = np.random.rand(10).astype(np.float64)
f90module.test_array()

f2py + OpenMP#

%env OMP_NUM_THREADS=4
%%fortran 
subroutine hello( )
  integer :: i
  do i = 1, 4
    call sleep(1)
  end do
end subroutine
%%time
hello()
%%fortran --f90flags "-fopenmp" --extra "-L/usr/local/lib -lgomp"
subroutine hello_omp( )
  integer :: i
  !$OMP PARALLEL PRIVATE(I)
  !$OMP DO 
  do i = 1, 4
    call sleep(1)
  end do
  !$OMP END DO
  !$OMP END PARALLEL

end subroutine
%%time
hello_omp()

Conclusions#

pros#

  • Easy to use, it works with modern fortran, legacy fortran and also C.

  • Works with common and modules and arrays dynamically allocated.

  • Python function callback can be very useful combined with Sympy

  • Documentation is automatically generated

  • All fortran compilers are supported: GNU, Portland, Sun, Intel,…

  • F2py is integrated in numpy library.

cons#

  • Derived types and fortran pointers are not well supported.

  • Absolutely not compatible with fortran 2003-2008 new features (classes)

  • f2py is maintained but not really improved. Development is stopped.

distutils#

setup.py#

from numpy.distutils.core import Extension, setup
ext1 = Extension(name = 'scalar',
                 sources = ['scalar.f'])
ext2 = Extension(name = 'fib2',
                 sources = ['fib2.pyf','fib1.f'])

setup(name = 'f2py_example', ext_modules = [ext1,ext2])

Compilation

python3 setup.py build_ext --inplace

Exercice: Laplace problem#

  • Replace the laplace function by a fortran subroutine

%%time
%matplotlib inline
%config InlineBackend.figure_format = 'retina'
import numpy as np
import matplotlib.pyplot as plt
import itertools
# Boundary conditions
Tnorth, Tsouth, Twest, Teast = 100, 20, 50, 50

# Set meshgrid
n, l = 64, 1.0
X, Y = np.meshgrid(np.linspace(0,l,n), np.linspace(0,l,n))
T = np.zeros((n,n))

# Set Boundary condition
T[n-1:, :] = Tnorth
T[:1, :] = Tsouth
T[:, n-1:] = Teast
T[:, :1] = Twest

def laplace(T, n):
    residual = 0.0
    for i in range(1, n-1):
        for j in range(1, n-1):
            T_old = T[i,j]
            T[i, j] = 0.25 * (T[i+1,j] + T[i-1,j] + T[i,j+1] + T[i,j-1])
            if T[i,j]>0:
                residual=max(residual,abs((T_old-T[i,j])/T[i,j]))
    return residual

residual = 1.0   
istep = 0
while residual > 1e-5 :
    istep += 1
    residual = laplace(T, n)
    print ((istep, residual), end="\r")

print("iterations = ",istep)
plt.rcParams['figure.figsize'] = (10,6.67)
plt.title("Temperature")
plt.contourf(X, Y, T)
plt.colorbar()
%%fortran
# %load solutions/fortran/laplace_fortran.F90
subroutine laplace_fortran( T, n, residual )

  real(8), intent(inout) :: T(0:n-1,0:n-1) ! Python indexing
  integer, intent(in)    :: n
  real(8), intent(out)   :: residual
  real(8) :: T_old
              
  residual = 0.0
  do i = 1, n-2  
    do j = 1, n-2
        T_old = T(i,j)
        T(i, j) = 0.25 * (T(i+1,j) + T(i-1,j) + T(i,j+1) + T(i,j-1))
            if (T(i,j) > 0) then
                residual=max(residual,abs((T_old-T(i,j))/T(i,j)))
            end if
    end do
  end do
        
end subroutine laplace_fortran
%%time
%matplotlib inline
%config InlineBackend.figure_format = 'retina'
import numpy as np
import matplotlib.pyplot as plt
import itertools
# Boundary conditions
Tnorth, Tsouth, Twest, Teast = 100, 20, 50, 50

# Set meshgrid
n, l = 64, 1.0
X, Y = np.meshgrid(np.linspace(0,l,n), np.linspace(0,l,n))
T = np.zeros((n,n), order='F')  ## We need to declare a new order in memory

# Set Boundary condition
T[n-1:, :] = Tnorth
T[:1, :] = Tsouth
T[:, n-1:] = Teast
T[:, :1] = Twest

residual = 1.0   
istep = 0
while residual > 1e-5 :
    istep += 1
    residual = laplace_fortran(T, n)
    print ((istep, residual), end="\r")

print()
print("iterations = ",istep)
plt.rcParams['figure.figsize'] = (10,6.67)
plt.title("Temperature")
plt.contourf(X, Y, T)
plt.colorbar()

References#