Development Process Details#
Build from source#
Note
The build process of smash
is similar to SciPy but way simpler.
This is why most of the information written about the smash
build system can also be found in the
Building from source section of SciPy.
System-level dependencies#
smash
uses compiled code for speed, which means you need compilers and some other system-level
(i.e, non-Python / non-PyPI) dependencies to build it on your system.
Anaconda (recommended)#
If you are using Conda, all the dependencies will be installed automatically by running the following command:
conda env create -f environment-dev.yml
It will create a Conda environment called smash-dev
that can be activated as follows:
conda activate smash-dev
Once smash-dev
is created, it is recommended to update pip and your Conda environment
to ensure you have the latest package versions and to prevent any conflicts:
(smash-dev) python3 -m pip install --upgrade pip
(smash-dev) conda update --all
Linux#
If you want to use the system Python and pip, you will need:
C and Fortran compilers (typically
gcc
andgfortran
).Python header files (typically a package named
python3-dev
orpython3-devel
)Java Runtime Environment (typically
openjdk-17-jdk
)
This can be installed on Ubuntu/Debian Linux:
sudo apt-get install -y build-essential gfortran python3-pip python3-dev openjdk-17-jdk
Windows#
Warning
Section in development. Information on compiling smash
under Windows can be found in the
pyproject.toml
, meson.build
and wheel.yml
workflow.
Build#
Once the system-level dependencies installed and the git cloned, smash
can be built using either the make
or make edit
command.
make
This command build
smash
and install it in the current environment. This command is equivalent to:pip install .
Hint
Remembering that we need to move out of the root of the repository to ensure we pick up the package and not the local
smash/
source directory.cd doc python -c "import smash; print(smash.__version__)"
make-edit
This command build
smash
and install it in editable mode. It automatically rebuilds itself if a file is modified, whether in Python or Fortran. This command is equivalent to:pip install --no-build-isolation --config-settings=editable-verbose=true --editable .
Warning
The adjoint code is not part of the build but is directly a source of smash
. If you modify a file
that needs to be differentiated, you will still need to regenerate the adjoint code. See the section
Automatic differentiation
On Linux, smash
can be built with or without dependency on OpenMP.
By default, the dependency is activated. To build without it, simply add the following option to the build command:
make use-openmp=false
# Equivalent to
pip install -Csetup-args=-Duse-openmp=false .
Or in editable mode
make edit use-openmp=false
# Equivalent to
pip install -Csetup-args=-Duse-openmp=false --no-build-isolation --config-settings=editable-verbose=true --editable .
Understanding the build steps#
Building smash
relies on the following tools, which can be considered part of the build system:
meson
: the Meson build system, installable as a pure Python package from PyPI or conda-forge.ninja
: the build tool invoked by Meson to do the actual building (e.g. invoking compilers). Installable also from PyPI (on all common platforms) or conda-forge.meson-python
: the Python build backend (i.e., the thing that gets invoked via a hook inpyproject.toml
by a build frontend like pip or pypa/build). This is a thin layer on top of Meson, with as main roles (a) interface with build frontends, and (b) produce sdists and wheels with valid file names and metadata.
To build smash
under Meson, several meson.build
files are used in different places in the source code.
smash/meson.build
Second file in the tree, where simply all the Python files are installed, looping through the subfolders.
smash/factory/mesh/meson.build
Specific file managing the Python extension
_libmesh.cpython*.so
for the mesh. The generation of this Python extension consists of a call to F2PY on the Fortran filemw_mesh.f90
.
smash/fcore/meson.build
Specific file managing the Python extension
_libfcore.cpython*.so
for the Fortran core. This file is the most complicated of all, and can be summarised as follows:- Declare all the sources
First, the Python, C and Fortran sources are declared. There is no need to worry about the order in which Fortran files are declared. Meson takes care of managing dependencies between modules. However, it is important to declare the
f90
files in the correct variables. There are 5 types of file:- Module (i.e. file name starting with
m_
) Fortran module
m_f90_sources = [ 'common/m_array_creation.f90', ... ]
- Module (i.e. file name starting with
- Wrapped module (i.e. file name starting with
mw_
) Fortran module wrapped with f90wrap
mw_f90_sources = [ 'forward/mw_forward.f90', ... ]
- Wrapped module (i.e. file name starting with
- Differentiated module (i.e. file name starting with
md_
) Fortran module differentiated with Tapenade
md_f90_sources = [ 'common/md_constant.f90', ... ]
- Differentiated module (i.e. file name starting with
- Non module file (i.e. file name starting without prefix)
Simple Fortran file.
f90_sources = [ 'forward/forward.f90', ] + m_f90_sources + md_f90_sources + mw_f90_sources + mwd_f90_sources
Note
We recommend that you do not insert a Fortran file without a module, for reasons of wrapping and readability. The only file of this type contains the top differentiation routine.
- Choose adjoint file
Two adjoint files are available but only one must be used depending on the OS and
use-openmp
option. On Windows, only choose the non OpenMP file.if host_machine.system() == 'windows' f90_sources += 'forward/forward_db.f90' else if get_option('use-openmp') f90_sources += 'forward/forward_openmp_db.f90' else f90_sources += 'forward/forward_db.f90' endif endif
Note
The
use-openmp
option is declared in themeson.options
file
- Generate f90wrap files
Once all the sources declared, we can call f90wrap to generate the Python and Fortran wrapped files. Generated Python files will be installed and Fortran files used to generate the Python extension. To generate this files, a call to a self-made
f90wrap/generate_f90wrap.py
file is done. It is just a wrapper around thef90wrap
command to handle monkey patchings and build directory.f90wrap = [ py, files('../../f90wrap/generate_f90wrap.py'), '@INPUT@', '-k', files('../../f90wrap/kind_map'), '--build-dir', '@OUTDIR@', ] ... f90wrap_sources = custom_target( input: mw_f90_sources + mwd_f90_sources, output: [f90wrap_f90_output, f90wrap_py_output], command: f90wrap + ['-m', 'libfcore'], install: true, install_dir: [f90wrap_f90_install_dir, f90wrap_py_install_dir], )
- Generate F2PY files
Once the Fortran f90wrap files are generated, we can call F2PY to generate the C file used generate the Python extension.
f2py_f90wrap = ['f2py-f90wrap', '@INPUT@', '--build-dir', '@OUTDIR@', '--lower'] f2py_f90wrap_sources = custom_target( input: f90wrap_f90_sources, output: ['_libfcoremodule.c', '_libfcore-f2pywrappers.f'], command: f2py_f90wrap + ['-m', '_libfcore'], )
- Handle dependencies and link arguments
Juste before generating the Python extension, dependencies and link arguments are declared depending on the OS and
use-openmp
. On Windows,libquadmath
must be explicitly linked and we link all the libraries staticly with-static
.link_args = [] dependencies = [fortranobject_dep] if host_machine.system() == 'windows' link_args += ['-lquadmath', '-static'] else if get_option('use-openmp') dependencies += openmp_dep endif endif
Fortran guideline#
Global convention#
The aim of this section is to show how to integrate new functions into the Fortran code of smash.
Style convention#
Here are the conventions that have been applied on the content of a Fortran file (most of the time …):
Use lowercase for all Fortran constructs (
do
,subroutine
,module
, …)For other names use all lowercase and
snake_case
as multiple-word identifier format (optimize
,get_parameters
,set_states
, …).Use 4 spaces indentation.
Note
fprettify is used to format Fortran file. It can be used as follows:
fprettify --indent 4 mwd_parameters.f90
fprettify --indent 4 *.f90
or using the make format
command
make format
File name convention#
If you want to integrate a new Fortran file, a naming convention must be respected in order to make the different installation processes understand if the file is a module and if it must be wrapped and/or differentiated.
The structure of a Fortran file name can be written as follows: <prefix>_<name>.f90
using lowercase and snake_case
as multiple-word identifier format.
There are no constraints on <name>
here are those on the <prefix>
:
m
: the file is a module (m_array_creation.f90
)mw
: the file is a module and is wrapped (mw_optimize.f90
)md
: the file is a module and is differentiated (md_constant.f90
)mwd
: the file is a module, is wrapped and differentiated (mwd_setup.f90
)
Note
We strongly recommand the use of module. Specifically if the file contains sources to be wrapped or differentiated.
Floating point convention#
Most of the real variables are single precision floating-point. In some functions, these variables are casted into double precision floating-point.
Therefore, two constants sp
and dp
are used to precise the floating-point precision, respectively, simple precision and double precision.
real(sp) :: foo = 2._sp
real(dp) :: bar = 0._dp
bar = real(foo, dp)
Compile#
Compile a pre-existing file#
If you are editing a pre-existing file, there are no particular constraints before compiling the code. Compile with the following command:
make
Compile a new file#
If you are creating a new file, respecting the naming convention
(File name convention),
you must fill in the samsh/fcore/meson.build
file to declare the new file
Wrapping#
The Fortran code is wrapped using the f90wrap library. Here are the different steps to wrap smash
code efficiently. We assume here that we are integrating a wrapped module from scratch. Certain steps can be repeated if you are adding to
pre-existing files.
Hint
Quite a few examples are also available in the f90wrap GitHub directory in the examples folder (see here
Vector2 case#
We are going to create a derived type called Vector2DT
containing two real variables, x
and y
, and a set of subroutines/functions
associated with this derived type.
Create new wrapped files#
As explained in the File name convention section, a
Fortran file will be automatically wrapped if it name contains the prefix mw
or mwd
. We will consider the following
Fortran files: mw_vector2.f90
and mw_vector2_manipulation.f90
. The first file will contain the implementation of the derived type
Vector2DT
and the second will contain all the subroutines/functions that manipulate the derived type. It might well have been possible to
do everything in a single file, but it was decided in smash
to separate them.
mw_vector2.f90
(this file can be stored in the foldersmash/fcore/derived_type
)
module mw_vector2
...
end module mw_vector2
mw_vector2_manipulation.f90
(this file can be stored in the foldersmash/fcore/routine
)
module mw_vector2_manipulation
...
end module mw_vector2_manipulation
Note
The entire file will be wrapped, so it is advisable to separate the functions to be wrapped from those that are not.
The files (even empty ones) can be compiled and wrapped (see the Compile a new file section) and imported in Python as follows:
>>> import smash.fcore._mw_vector2
>>> import smash.fcore._mw_vector2_manipulation
Derived type implementation#
First, we will implement the derived type Vector2DT
in the mw_vector2.f90
file.
Note
We add the suffix DT
for each derived type because Fortran is case insensitive and will not differentiate between vector2
and Vector2
.
module mw_vector2
use md_constant, only: sp
implicit none
type Vector2DT
real(sp) :: x
real(sp) :: y
end type Vector2DT
end module mw_vector2
Note
sp
is equal to 4
, it is simple precision
(see the Floating point convention section)
A wrapped derived type is interpreted as a Python class. Let’s compile, initialize it and view what it contains:
>>> from smash.fcore._mw_vector2 import Vector2DT
>>> v = Vector2DT()
>>> v
Vector2DT
x: 4.201793856028541e+18
y: 3.0741685710357837e-41
>>> v.x
4.201793856028541e+18
>>> v.y
3.0741685710357837e-41
We can see that the 2 variables, x
and y
present in the original derived type are accessible in Python as class properties but filled with garbage values because they were not
initialized. There two ways to initialize the values of a derived type:
Assign values in the declaration of the derived type variables
module mw_vector2
use md_constant, only: sp
implicit none
type Vector2DT
real(sp) :: x = 0._sp
real(sp) :: y = 0._sp
end type Vector2DT
end module mw_vector2
Create a specific initialization subroutine which will be interpreted as a Python class constructor (
__init__
function). f90wrap will automatically detects derived type initialization subroutine if the subroutine name follows the convention:<derived-type-name>_initialise
. In our case, the subroutine must be called:Vector2DT_initialise
. Let’s write the initialization subroutine after adding thecontains
statement.
module mw_vector2
use md_constant, only: sp
implicit none
type Vector2DT
real(sp) :: x
real(sp) :: y
end type Vector2DT
contains
subroutine Vector2DT_initialise(this)
implicit none
type(Vector2DT), intent(inout) :: this
this%x = 0._sp
this%y = 0._sp
end subroutine Vector2DT_initialise
end module mw_vector2
The two methods in this example are equivalent and here is the result in Python:
>>> v = Vector2DT()
>>> v
Vector2DT
x: 0.0
y: 0.0
We successfully initialize the derived type with default values. However, the second method, using an initialization function, is more flexible. We can, for example, not define default values but initialize the derived type with values from Python. Let’s rewrite the initialize subroutine and add arguments.
module mw_vector2
use md_constant, only: sp
implicit none
type Vector2DT
real(sp) :: x
real(sp) :: y
end type Vector2DT
contains
subroutine Vector2DT_initialise(this, x, y)
implicit none
type(FooDT), intent(inout) :: this
real(sp), intent(in) :: x
real(sp), intent(in) :: y
this%x = x
this%y = y
end subroutine Vector2DT_initialise
end module mw_vector2
We add 2 arguments which correspond to each variable of the derived type to initialize. On the Python side, this is how it translates:
>>> v = Vector2DT(0, 0)
>>> v
Vector2DT
x: 0.0
y: 0.0
>>> v = Vector2DT(1, 1)
>>> v
Vector2DT
x: 1.0
y: 1.0
It is also possible to modify the values once initialization is complete, since each element of the derived type is a property with a getter and a setter.
>>> v = Vector2DT(0, 0)
>>> v
Vector2DT
x: 0.0
y: 0.0
>>> v.x = 3
>>> v.y = 2
v
Vector2DT
x: 3.0
y: 2.0
Functions implementation#
We can now implement a number of subroutines/functions in the mw_vector2_manipulation.f90
file to manipulate this derived type. We need first to import the module where
the Vector2DT
derived type is defined mw_vector2
and them in a contains
statement add the functions.
module mw_vector2_manipulation
use md_constant, only: sp
use mw_vector2, only: Vector2DT
implicit none
contains
function vector2_add_value(v, add) result(res)
type(Vector2DT), intent(in) :: v
real(sp), intent(in) :: add
type(Vector2DT) :: res
res%x = v%x + add
res%y = v%y + add
end function vector2_add_value
function vector2_dot_product(v1, v2) result(res)
type(Vector2DT), intent(in) :: v1
type(Vector2DT), intent(in) :: v2
real(sp) :: res
res = v1%x*v2%x + v1%y*v2%y
end function vector2_dot_product
end module mw_vector2_manipulation
We have added two functions, one to add a value to each element of the Vector2DT
and the other one to compute
the dot product between two Vector2DT
, so let’s see how this translates into Python:
>>> from smash.fcore._mw_vector2 import Vector2DT
>>> from smash.fcore._mw_vector2_manipulation import vector2_add_value, vector2_dot_product
>>> v = Vector2DT(0, 0)
>>> vector2_add_value(v, 5)
Vector2DT
x: 5.0
y: 5.0
>>> v1 = Vector2DT(1, 1)
>>> v2 = Vector2DT(2, 3)
>>> vector2_dot_product(v1, v2)
5.0
This completes the first example of Fortran wrapping in smash
. The next examples will be less detailed but will aim to expose a wider range of functionality,
variable types, allocation management, string management, etc.
Matrix2 case#
We are going to create a derived type called Matrix2DT
containing one allocatable real variable of 2 dimensions, vle
, two integer variables
representing the number of rows and columns of the matrix, n
and m
, respectively and a set of subroutines/functions associated with
this derived type. Similar to the Vector2 case section, two files
are created, mw_matrix2.f90
and mw_matrix2_manipulation.f90
. The aim of this case is to illustrate how arrays can are handled.
mw_matrix2.f90
(this file can be stored in the foldersmash/fcore/derived_type
)
module mw_matrix2
use md_constant, only: sp
implicit none
type Matrix2DT
integer :: n
integer :: m
real(sp), dimension(:, :), allocatable :: vle
end type Matrix2DT
contains
subroutine Matrix2DT_initialise(this, n, m, vle0)
implicit none
type(Matrix2DT), intent(inout) :: this
integer, intent(in) :: n, m
real(sp), intent(in) :: vle0
this%n = n
this%m = m
allocate (this%vle(this%n, this%m))
this%vle(:, :) = vle0
end subroutine Matrix2DT_initialise
end module mw_matrix2
mw_matrix2_manipulation.f90
(this file can be stored in the foldersmash/fcore/routine
)
module mw_matrix2_manipulation
use md_constant, only: sp
use mw_matrix2, only: Matrix2DT, Matrix2DT_initialise
implicit none
contains
function matrix2_add_value(mat, add) result(res)
implicit none
type(Matrix2DT), intent(inout) :: mat
real(sp), intent(in) :: add
type(Matrix2DT) :: res
call Matrix2DT_initialise(res, mat%n, mat%m, 0._sp)
res%vle(:, :) = mat%vle(:, :) + add
end function matrix2_add_value
function matrix2_transpose(mat) result(res)
implicit none
type(Matrix2DT), intent(in) :: mat
type(Matrix2DT) :: res
integer :: i, j
call Matrix2DT_initialise(res, mat%m, mat%n, 0._sp)
! Could also use directly the Fortran intrinsic function TRANSPOSE
! res%vle = TRANSPOSE(mat%vle)
do i = 1, mat%m
do j = 1, mat%n
res%vle(i, j) = mat%vle(j, i)
end do
end do
end function matrix2_transpose
end module mw_matrix2_manipulation
This translates into Python:
>>> from smash.fcore._mw_matrix2 import Matrix2DT
>>> mat = Matrix2DT(2, 3, 0)
>>> mat
Matrix2DT
m: 3
n: 2
vle: array([[0., 0., 0.],
[0., 0., 0.]], dtype=float32)
>>> type(mat.vle)
<class 'numpy.ndarray'>
Fortran arrays are casted to numpy.ndarray
when accessed in Python. So all the methods associated with a numpy.ndarray
can be used.
>>> from smash.fcore._mw_matrix2 import Matrix2DT
>>> from smash.fcore._mw_matrix2_manipulation import matrix2_add_value, matrix2_transpose
>>> mat = Matrix2DT(2, 3, 0)
>>> mat
Matrix2DT
m: 3
n: 2
vle: array([[0., 0., 0.],
[0., 0., 0.]], dtype=float32)
>>> mat.vle.shape
(2, 3)
>>> mat.vle.dtype
dtype('float32'
>>> mat.vle[0, :] = 2
>>> mat
Matrix2DT
m: 3
n: 2
vle: array([[2., 2., 2.],
[0., 0., 0.]], dtype=float32)
>>> matrix2_add_value(mat, 4)
Matrix2DT
m: 3
n: 2
vle: array([[6., 6., 6.],
[4., 4., 4.]], dtype=float32)
>>> matrix2_transpose(mat)
Matrix2DT
m: 2
n: 3
vle: array([[0., 2.],
[0., 2.],
[0., 2.]], dtype=float32)
Matrix2Array case#
We are going to create a derived type called Matrix2ArrayDT
containing one allocatable Matrix2DT
type variable of 1 dimension.
The aim of this case is to illustrate how derived type arrays are handled. We will keep the previous files created for Matrix2DT
(i.e. mw_matrix2.f90
and mw_matrix2_manipulation.f90
).
module mw_matrix2
use md_constant, only: sp
implicit none
type Matrix2DT
integer :: n
integer :: m
real(sp), dimension(:, :), allocatable :: vle
end type Matrix2DT
type Matrix2ArrayDT
integer :: n
type(Matrix2DT), dimension(:), allocatable :: mat
end type Matrix2ArrayDT
contains
subroutine Matrix2DT_initialise(this, n, m, vle0)
implicit none
type(Matrix2DT), intent(inout) :: this
integer, intent(in) :: n, m
real(sp), intent(in) :: vle0
this%n = n
this%m = m
allocate (this%vle(this%n, this%m))
this%vle(:, :) = vle0
end subroutine Matrix2DT_initialise
subroutine Matrix2ArrayDT_initialise(this, n, n_arr, m_arr, vle0_arr)
implicit none
type(Matrix2ArrayDT), intent(inout) :: this
integer, intent(in) :: n
integer, dimension(n), intent(in) :: n_arr, m_arr
real(sp), dimension(n), intent(in) :: vle0_arr
integer :: i
this%n = n
allocate (this%mat(this%n))
do i = 1, this%n
call Matrix2DT_initialise(this%mat(i), n_arr(i), m_arr(i), vle0_arr(i))
end do
end subroutine Matrix2ArrayDT_initialise
end module mw_matrix2
Here we create a derived type Matrix2ArrayDT
which contains an array of Matrix2DT
. To initialize this derived type, we pass the number of
Matrix2DT
that we want to allocate n
, the number of rows and columns for each allocated matrix n_arr
and m_arr
, respectively and
an initial value for each vle0_arr
. This translates into Python:
>>> import numpy as np
>>> from smash.fcore._mw_matrix2 import Matrix2ArrayDT
>>> n = 2
>>> n_arr = np.array([2, 3], dtype=np.int32)
>>> m_arr = np.array([4, 1], dtype=np.int32)
>>> vle_arr = np.array([1, 5], dtype=np.float32)
>>> mat_arr = Matrix2ArrayDT(n, n_arr, m_arr, vle_arr)
>>> mat_arr
Matrix2ArrayDT
mat: ['Matrix2DT', 'Matrix2DT']
n: 2
It allows us to create an array of Matrix2DT
that can have different shapes. Here (2, 4)
and (3, 1)
. We can iterate over as follows:
>>> import numpy as np
>>> from smash.fcore._mw_matrix2 import Matrix2ArrayDT
>>> n = 2
>>> n_arr = np.array([2, 3], dtype=np.int32)
>>> m_arr = np.array([4, 1], dtype=np.int32)
>>> vle_arr = np.array([1, 5], dtype=np.float32)
>>> mat_arr = Matrix2ArrayDT(n, n_arr, m_arr, vle_arr)
>>> mat_arr
Matrix2ArrayDT
mat: ['Matrix2DT', 'Matrix2DT']
n: 2
>>> for m in mat_arr.mat.items():
>>> m
Matrix2DT
m: 4
n: 2
vle: array([[1., 1., 1., 1.],
[1., 1., 1., 1.]], dtype=float32)
Matrix2DT
m: 1
n: 3
vle: array([[5.],
[5.],
[5.]], dtype=float32)
>>> mat_arr.mat[0]
Matrix2DT
m: 4
n: 2
vle: array([[1., 1., 1., 1.],
[1., 1., 1., 1.]], dtype=float32)
>>> mat_arr.mat[1]
Matrix2DT
m: 1
n: 3
vle: array([[5.],
[5.],
[5.]], dtype=float32)
Character/String case#
We are going to create a derived type called CharacterDT
containing a character c
and character array c_arr
in order to get
into the details of this specific edge case of the wrapping and how we handle it in smash
.
Let’s create a mw_character.f90
file.
module mw_character
use md_constant, only: sp, lchar
implicit none
type CharacterDT
character(lchar) :: c = "foo"
character(lchar), dimension(2) :: c_arr = "bar"
end type CharacterDT
end module mw_character
This translates into Python:
>>> from smash.fcore._mw_character import CharacterDT
>>> char = CharacterDT()
>>> char
CharacterDT
c: b'foo'
c_arr: array([[ 98, 98],
[ 97, 97],
[114, 114],
...
[ 32, 32],
[ 32, 32]], dtype=uint8)
>>> type(char.c)
<class 'bytes'>
>>> type(char.c_arr), char.c_arr.dtype
<class 'numpy.ndarray'>, dtype('uint8')
As you can see, when wrapped to Python, a Fortran character is interpreted as bytes
and character array as a numpy.ndarray
of dtype uint8
(unsigned 8 bits integer). To get something interpretable, we can cast bytes
to str
with the decode
method and decode each ASCII
value in the character array.
>>> from smash.fcore._mw_character import CharacterDT
>>> char = CharacterDT()
>>> char
CharacterDT
c: b'foo'
c_arr: array([[ 98, 98],
[ 97, 97],
[114, 114],
...
[ 32, 32],
[ 32, 32]], dtype=uint8)
>>> char.c.decode()
'foo'
# Cast to bytes
>>> char.c_arr.tobytes(order="F")
b'bar
bar
'
# Decode with utf-8 encoding
>>> char.c_arr.tobytes(order="F").decode()
'bar
bar
'
# Split by whitespaces
>>> char.c_arr.tobytes(order="F").decode().split()
['bar', 'bar']
We have managed to interpret these values, but it’s not particularly conveniente. Moreover, how can we change the values in Python ?
>>> from smash.fcore._mw_character import CharacterDT
>>> char = CharacterDT()
>>> char
CharacterDT
c: b'foo'
c_arr: array([[ 98, 98],
[ 97, 97],
[114, 114],
...
[ 32, 32],
[ 32, 32]], dtype=uint8)
>>> char.c = "baz"
>>> char.c
"baz"
>>> char.c_arr = ["buz", "buz"]
ValueError: invalid literal for int() with base 10: 'buz'
>>> char.c_arr = np.array(["buz", "buz"])
ValueError: invalid literal for int() with base 10: 'buz'
It’s ok for a character but not for the character array. To get around this, some self made Fortran directives can be inserted at the definition
of the variables in the derived type. !$F90W char
for character and !$F90W char-array
for character array.
module mw_character
use md_constant, only: sp, lchar
implicit none
type CharacterDT
character(lchar) :: c = "foo" !$F90W char
character(lchar), dimension(2) :: c_arr = "bar" !$F90W char-array
end type CharacterDT
end module mw_character
This allows us to manipulate this derived type in Python in a more practical way:
>>> import numpy as np
>>> from smash.fcore._mw_character import CharacterDT
>>> char = CharacterDT()
>>> char
CharacterDT
c: 'foo'
c_arr: array(['bar', 'bar'], dtype='<U3')
>>> char.c = "baz"
>>> char.c_arr = np.array(["buz", "buz"])
>>> char
CharacterDT
c: 'baz'
c_arr: array(['buz', 'buz'], dtype='<U3')
How it works? The file is parsed and for each directive encountered, a decorator is added to the getters and setters of the f90wrap Python file
associated. Decorators are defined in this file smash/fcore/_f90wrap_decorator.py
.
Array indexing case#
An other edge case is to manipulate values that contain indices (i.e. location of the maximum value of a matrix). Why this is a edge case ?
because Python is 0-based indexed and Fortran is 1-based indexed (by default). We will create a derived type called ArrayIndexDT
containing an array of real r_arr
and an integer ind
in order to get into the details of this specific edge case of the wrapping
and how we handle it in smash
. Let’s create mw_array_index.f90
and mw_array_index_manipulation.f90
files.
module mw_array_index
use md_constant, only: sp
implicit none
type ArrayIndexDT
integer :: ind = 1
real(sp), dimension(10) :: r_arr = 0._sp
end type ArrayIndexDT
end module mw_array_index
module mw_array_index_manipulation
use md_constant, only: sp
use mw_array_index, only: ArrayIndexDT
implicit none
contains
function array_index_at_ind(a) result(res)
implicit none
type(ArrayIndexDT), intent(in) :: a
real(sp) :: res
res = a%r_arr(a%ind)
end function array_index_at_ind
end module mw_array_index_manipulation
This translates in Python:
>>> import numpy as np
>>> from smash.fcore._mw_array_index import ArrayIndexDT
>>> from smash.fcore._mw_array_index_manipulation import array_index_at_ind
>>> ai = ArrayIndexDT()
>>> ai
ArrayIndexDT
ind: 1
r_arr: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.], dtype=float32)
>>> ai.r_arr = np.arange(0, ai.r_arr.size)
>>> ai
ArrayIndexDT
ind: 1
r_arr: array([0., 1., 2., 3., 4., 5., 6., 7., 8., 9.], dtype=float32)
Now we can for exemple store the indice of the maximum value of the array in ai.ind
and try to access the maximum value back with this indice
from Python and Fortran
>>> ai.ind = np.argmax(ai.r_arr)
>>> ai
ArrayIndexDT
ind: 9
r_arr: array([0., 1., 2., 3., 4., 5., 6., 7., 8., 9.], dtype=float32)
# Access from Python
>>> ai.r_arr[ai.ind]
9.0
# Access from Fortran
>>> array_index_at_ind(ai)
8.0
As you can see, the value are different and it’s because the arrays are not indexed in the same way. The value of ai.ind
is set to 9
which
is correct in Python but should be 10
in Fortran. As a result, we’d need to manipulate the index depending on whether we calculated it in
Fortran or Python, which isn’t practical and is prone to out-of-range accesses. To get around this, a self made Fortran directive !$F90W index
can be used to substract 1 from the value of a variable storing indices when passing from Fortran to Python or add 1 the other way around.
Let’s do the same thing with the new directive.
Note
If the variable storing the indices is an array, the directive is !$F90W index-array
instead of !$F90W index
.
module mw_array_index
use md_constant, only: sp
implicit none
type ArrayIndexDT
integer :: ind = 1 !$F90W index
real(sp), dimension(10) :: r_arr = 0._sp
end type ArrayIndexDT
end module mw_array_index
>>> import numpy as np
>>> from smash.fcore._mw_array_index import ArrayIndexDT
>>> from smash.fcore._mw_array_index_manipulation import array_index_at_ind
>>> ai = ArrayIndexDT()
>>> ai
ArrayIndexDT
ind: 0
r_arr: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.], dtype=float32)
>>> ai.r_arr = np.arange(0, ai.r_arr.size)
>>> ai
ArrayIndexDT
ind: 0
r_arr: array([0., 1., 2., 3., 4., 5., 6., 7., 8., 9.], dtype=float32)
>>> ai.ind = np.argmax(ai.r_arr)
>>> ai
ArrayIndexDT
ind: 9
r_arr: array([0., 1., 2., 3., 4., 5., 6., 7., 8., 9.], dtype=float32)
# Access from Python
>>> ai.r_arr[ai.ind]
9.0
# Access from Fortran
>>> array_index_at_ind(ai)
9.0
Automatic differentiation#
The Fortran code is automatically differentiated using the Tapenade. The use
of Tapenade can quickly become complex and a reason to give up in the development of smash
. The aim of this
section is to explore the subtleties of this not-so-automatic differentiation. The adjoint code is not part
of the build system for reasons of managing Tapenade under different OS as well as to facilitate
debugging. Two adjoint codes are in the sources, one with OpenMP directives
forward_openmp_db.f90
and the other without forward_db.f90
. Depending on the OS and the
use-openmp
option, one of the two files is used.
To build the adjoint codes, a Java Runtime Environment must be installed before running the following command:
make tap
This command generates the two adjoint files.
Differentiated files#
A Fortran file will be automatically differentiated if it name contains the prefix md
or mwd
.
There is just one exception with the file fcore/forward/forward.f90
which is not a module and contains
the top differentiation routine base_foward_run
. This file is not a module because Tapenade is complaining about.
If an operator needs to be added to the direct model, it is necessary to implement it in a pre-existing file
containing md
or mwd
or to insert it in a new file containing these same prefixes. The result of the differentiation
(i.e. the adjoint and tangent linear model) is writted in the fcore/forward/forward_openmp_db.f90
and
fcore/forward/forward_db.f90
file.
Note
There is no need to modify the files forward_openmp_db.f90
and forward_db.f90
,
apart from for debugging purposes, as this file is constantly updated with the sources as soon as the make tap
command is called.
Tapenade usage#
The call to Tapenade to generate the adjoint files is made with the make tap
command. This command calls
a Python file tapenade/generate_tapenade.py
. The generation of the adjoints files takes place in 4 stages:
Patch Fortran files
Before calling the Tapenade executable, this step allows you to make changes to the files to be differentiated. The modifications currently available are:
- Delete sections of code via a pair of directives.
For example, in the file
fcore/forward/md_simulation.f90
file, you can see how this pair of directives is used.subroutine store_time_step(setup, mesh, output, returns, checkpoint_variable, time_step) implicit none type(SetupDT), intent(in) :: setup type(MeshDT), intent(in) :: mesh type(OutputDT), intent(inout) :: output type(ReturnsDT), intent(inout) :: returns type(Checkpoint_VariableDT), intent(in) :: checkpoint_variable integer, intent(in) :: time_step integer :: i, k, time_step_returns do i = 1, mesh%ng k = mesh%rowcol_to_ind_ac(mesh%gauge_pos(i, 1), mesh%gauge_pos(i, 2)) output%response%q(i, time_step) = checkpoint_variable%ac_qz(k, setup%nqz) end do !$AD start-exclude if (allocated(returns%mask_time_step)) then if (returns%mask_time_step(time_step)) then time_step_returns = returns%time_step_to_returns_time_step(time_step) !% Return states if (returns%rr_states_flag) then do i = 1, setup%nrrs call ac_vector_to_matrix(mesh, checkpoint_variable%ac_rr_states(:, i), & & returns%rr_states(time_step_returns)%values(:, :, i)) end do end if !% Return discharge grid if (returns%q_domain_flag) then call ac_vector_to_matrix(mesh, checkpoint_variable%ac_qz(:, setup%nqz), & & returns%q_domain(:, :, time_step_returns)) end if end if end if !$AD end-exclude end subroutine store_time_step
Why has this section of code been removed from the differentiation? Firstly, Tapenade was returning a warning (for some reason) and secondly, quite simply, this section allows you to store intermediate results which can be useful when doing a forward run, but do not influence the calculation of gradients in the adjoint model.
- Handle OpenMP directives
The Tapenade parser does not detect all OpenMP directives. This is why we patch this file ourselves. For example, in the file
fcore/operator/md_gr_operator.f90
file, you can see how the OpenMP directives are used.beta = (9._sp/4._sp)*(86400._sp/setup%dt)**0.25_sp #ifdef _OPENMP !$OMP parallel do schedule(static) num_threads(options%comm%ncpu) & !$OMP& shared(setup, mesh, ac_prcp, ac_pet, ac_ci, ac_cp, beta, ac_ct, ac_kexc, ac_hi, ac_hp, ac_ht, & !$OMP& ac_qt) & !$OMP& private(row, col, k, pn, en, pr, perc, l, prr, prd, qr, qd) #endif do col = 1, mesh%ncol do row = 1, mesh%nrow
The directive pair
#ifdef _OPENMP
and#endif
allows to enable or disable the OpenMP directives if the-fopenmp
flag is passed to the compiler. However, Tapenade can not parse this. So if we want to generate an adjoint file with OpenMP directives, we patch as follows:beta = (9._sp/4._sp)*(86400._sp/setup%dt)**0.25_sp !$OMP parallel do schedule(static) num_threads(options%comm%ncpu) & !$OMP& shared(setup, mesh, ac_prcp, ac_pet, ac_ci, ac_cp, beta, ac_ct, ac_kexc, ac_hi, ac_hp, ac_ht, & !$OMP& ac_qt) & !$OMP& private(row, col, k, pn, en, pr, perc, l, prr, prd, qr, qd) do col = 1, mesh%ncol do row = 1, mesh%nrow
If we want to generate an adjoint file without OpenMP directive, we patch as follows:
beta = (9._sp/4._sp)*(86400._sp/setup%dt)**0.25_sp do col = 1, mesh%ncol do row = 1, mesh%nrow
Generate tapenade file
This step calls the Tapenade executable (supplied with the code sources) and generates the adjoint file from the source files.
tapenade_exe = os.path.join( os.path.abspath(os.path.dirname(__file__)), "tapenade_3.16", "bin", "tapenade" ) files = [os.path.join(".", os.path.basename(f)) for f in fortran_files] cmd_args = [ tapenade_exe, "-b", "-d", "-fixinterface", "-noisize", "-context", "-msglevel", "100", "-adjvarname", "%_b", "-tgtvarname", "%_d", "-o", module, "-head", r"base_forward_run(parameters.control.x)\(output.cost)", ] if openmp: cmd_args.append("-openmp") cmd_args.extend(files) subprocess.run(cmd_args, check=True)
It is possible to find in the Tapenade online documentation here or by running the executable with the
-h
option, information to understand what is done through the various options../smash/tapenade/tapenade_3.16/bin/tapenade -h Tapenade 3.16 (master) - 9 Oct 2020 17:47 - Java 11.0.21 Linux @@ TAPENADE_HOME=/home/fcolleoni/Documents/git/smash-repo/smash/tapenade/tapenade_3.16/bin/.. Builds a differentiated program. Usage: tapenade [options]* filenames options: -head, -root <proc> set the differentiation root procedure(s) See FAQ for refined invocation syntax, e.g. independent and dependent arguments, multiple heads... -tangent, -d differentiate in forward/tangent mode (default) -reverse, -b differentiate in reverse/adjoint mode ... -version display Tapenade version information Report bugs to <tapenade@lists-sop.inria.fr>.
There are still a few mysteries and sometimes it’s necessary to check into the code examples, available on Tapenade’s GitLab here.
In order to simplify this process, all the options are briefly detailed below.
-b
To differentiate in the reverse mode (adjoint model)
-d
To differentiate in the tangent mode (linear tangent model)
-fixinterface
To disable the use of activity to filter user-given (in)dependent vars
-noisize
To allow the use of dynamic calls to Fortran SIZE primitive whenever Tapenade needs the size of a variable
-openmp
To use the OpenMP directives to generate a parallel adjoint model
-context
To generate a complete differentiated code with its main procedure. This option is mandatory when the
-openmp
option is used
-msglevel 100
To set the level of detail of error messages (
100
is the max)
-adjvarname %_b
To set the extension for adjoint variables
-tgtvarname %_d
To set the extension for linear tangent variables
-o module
To set the name of the generated file. The name of the file will be
<module>_db.f90
-head "base_forward_run(parameters.control.x)\(output.cost)"
To set the differentiation root procedure.
base_forward_run
is the top differentiation routine to differentiate,output.cost
the dependent output variable whose derivate is required andparameters.control.x
the independent input variable with respect to which differentiation must be made
Patch tapenade file
After calling the Tapenade executable, this step allows you to make changes to the files to be differentiated. The only modification currently available is the ability to change the derived type used. By default, Tapenade generates a new version of an existing derived type by adding the suffix
_DIFF
and by removing all the variables that do not interact in differentiation. This may be useful to avoid storing variables unnecessarily, but it implies the use of specific derived types, which can only be used by the routines inforward_db.f90
and which make the code more complicated to use for very little. Most of the variables that take up memory are used in the differentiation scheme. For example, in the filefcore/forward/forward_db.f90
file, the following derived type was patched as follows:TYPE(PARAMETERSDT), INTENT(INOUT) :: parameters TYPE(PARAMETERSDT_DIFF), INTENT(INOUT) :: parameters_b
becomes
TYPE(PARAMETERSDT), INTENT(INOUT) :: parameters TYPE(PARAMETERSDT), INTENT(INOUT) :: parameters_b
Tapenade tips#
Here’s a list of some useful tips when using Tapenade:
Use simple Fortran 90 functionalities. Don’t get lost in trying to make the code more complex in order to modularise it or remove duplicated code, this generally leads to the use of functionality that is not taken into account in Tapenade.
At each generation of the adjoint model, a file containing potential error messages is available,
smash/fcore/forward/forward_db.msg
. As soon as an error occurs, consult the dedicated section in the Tapenade documentation, here
Python guideline#
Warning
Section in development
Test#
Tests are run with the make test
or make test-coverage
command using the pytest library:
make test
Run unit tests. This tests are also run in the continuous integration service (
CI
).
make test-coverage
Run unit tests with coverage. It will display coverage result in the terminal and generate a html file.
Note
The html file can be viewed with your browser
firefox smash/tests/htmlcov/index.html
There are two types of test available in smash
:
standard test
Test which do not require comparison with a file of expected values
baseline test
Test that require a comparison with a file of expected values (e.g.
smash/tests/baseline.hdf5
,smash/tests/simulated_discharge.hdf5
)
Standard test#
To set up a standard test, all you need to do is add a function whose name starts with test_
, either from a pre-existing file or
from a new file whose name must also start with test_
. Then all you have to do is write the desired tests and check the result with the
assert
command
def test_add_two():
x = 2
y = -2
assert add_two(x) == 4, "add_two.x"
assert add_two(y) == 0, "add_two.y"
Baseline test#
Setting up a test with a comparison with an expected value is a little more complex than a standard test. It breaks down into into two functions:
generic function
This function is not a test function in itself, it simply runs the calculations and stores the variables to be checked. This function can take any kind of arguments as input but must returns a dictionary.
test function
This function is the test function, which uses the
generic function
to generate the values to be compared and then compares them with a file in which the expected values have been stored.
def generic_add_sth_complex(**kwargs):
x = 3
y = [-2, 2]
res = {
"add_sth_complex.x": add_sth_complex(x),
"add_sth_complex.y": add_sth_complex(y),
}
return res
def test_add_sth_complex():
res = generic_add_sth_complex()
for key, value in res.items():
assert value, pytest.baseline[key], key
In this example, we can’t simply write what the result of add_sth_complex
(because it’s something complex). So we store the output value(s) of
this function in a file baseline.hdf5
and then compare this value(s) by rerunning the test with the same function.
As you can see, we compare the values with values stored in pytest.baseline
. It is possible with pytest to store
global variables at test runtime. This is done in the test_define_global_vars.py
file and pytest.baseline
is therefore the global variable
which stores data from the baseline.hdf5
file and which can be called in any function.
Now comes the time when changes have been made to the code and the add_sth_complex
function has been modified and still returns something
complex but different. If we run the tests again, they will fail because the expected value is not up to date. It is therefore necessary to
regenerate the expected values file (baseline.hdf5
). To do this, you need to run the make test-baseline
command, which will run the
smash/tests/gen_baseline.py
file, updating the baseline.hdf5
file (by calling all the functions starting with generic_
), writes a
diff_baseline.csv
file which logs the differences between the old and new baselines and generates a new baseline file new_baseline.hdf5
.
If the logs in the diff_baseline.csv
file seem consistent with your modifications (i.e. that tests that shouldn’t be modified aren’t modified
and conversely that tests that should be modified are modified), all you have to do is simply delete the baseline.hdf5
file and rename
new_baseline.hdf5
to baseline.hdf5
.
Note
To properly generate the diff_baseline.csv
file, please ensure that you have the baseline.hdf5
file from the latest commit
on the main branch before running the make test-baseline
command.
Documentation#
The smash
documentation is generated with the make doc
command using Sphinx.
This command will generate a build/html
folder in which it is possible to display the documentation on your browser.
firefox ./doc/build/html/index.html
Note
If you encounter any issues when compiling the documentation, try cleaning the doc
directory and then recompiling the documentation.
This can help eliminate any potential conflicts and bugs that may be causing the issue.
(smash-dev) make doc-clean
(smash-dev) make doc
Generate a new ReStructuredText file#
The documentation is written using files in ReStructuredText (rst
) format.
It is possible to generate a new file by hand without too much difficulty, but the doc/source/gen_rst.py
file makes it easier to create a new
one depending on where it is placed in the documentation architecture.
python3 gen_rst.py user_guide/quickstart/foo.rst
This will create the foo.rst
file in the desired location with the following header:
.. _user_guide.quickstart.foo:
===
Foo
===
Then, you need to call up this file in the desired toctree, for example in the file doc/source/user_guide/index.rst
.
.. _user_guide:
==========
User Guide
==========
Quickstart
----------
.. toctree::
:maxdepth: 1
quickstart/cance_first_simulation
quickstart/foo
User guide#
The user guide contains all the smash
tutorials. These tutorials are not hardcoded, the python commands written in the
.. ipython:: python
directives are executed and automatically generate the tutorial output. This is quite handy, as it means you don’t have to
update the documentation each time the source is modified, and adds an extra layer of testing since the documentation will not compile if
there is an error in executing a python command but which, on the other hand, requires a certain amount of computing time.
API reference#
Only the architecture of this section is defined in the rst
files. The content is automatically generated from the docstrings of each
smash
function. The style guide used for the docstrings is that of numpydoc.