Fixes for Intel and container build fixes
[alexxy/gromacs.git] / admin / containers / scripted_gmx_docker_builds.py
1 #!/usr/bin/env python
2 #
3 # This file is part of the GROMACS molecular simulation package.
4 #
5 # Copyright (c) 2020, by the GROMACS development team, led by
6 # Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
7 # and including many others, as listed in the AUTHORS file in the
8 # top-level source directory and at http://www.gromacs.org.
9 #
10 # GROMACS is free software; you can redistribute it and/or
11 # modify it under the terms of the GNU Lesser General Public License
12 # as published by the Free Software Foundation; either version 2.1
13 # of the License, or (at your option) any later version.
14 #
15 # GROMACS is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
18 # Lesser General Public License for more details.
19 #
20 # You should have received a copy of the GNU Lesser General Public
21 # License along with GROMACS; if not, see
22 # http://www.gnu.org/licenses, or write to the Free Software Foundation,
23 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
24 #
25 # If you want to redistribute modifications to GROMACS, please
26 # consider that scientific software is very special. Version
27 # control is crucial - bugs must be traceable. We will be happy to
28 # consider code for inclusion in the official distribution, but
29 # derived work must not be called official GROMACS. Details are found
30 # in the README & COPYING files - if they are missing, get the
31 # official version at http://www.gromacs.org.
32 #
33 # To help us fund GROMACS development, we humbly ask that you cite
34 # the research papers on the package. Check out http://www.gromacs.org.
35
36 """
37 Generates a set of docker images used for running GROMACS CI on Gitlab.
38 The images are prepared according to a selection of build configuration targets
39 that hope to cover a broad enough scope of different possible systems,
40 allowing us to check compiler types and versions, as well as libraries used
41 for accelerators and parallel communication systems. Each combinations is
42 described as an entry in the build_configs dictionary, with the script
43 analysing the logic and adding build stages as needed.
44
45 Based on the example script provided by the NVidia HPCCM repository.
46
47 Authors:
48     * Paul Bauer <paul.bauer.q@gmail.com>
49     * Eric Irrgang <ericirrgang@gmail.com>
50     * Joe Jordan <e.jjordan12@gmail.com>
51     * Mark Abraham <mark.j.abraham@gmail.com>
52
53 Usage::
54
55     $ python3 scripted_gmx_docker_builds.py --help
56     $ python3 scripted_gmx_docker_builds.py --format docker > Dockerfile && docker build .
57     $ python3 scripted_gmx_docker_builds.py | docker build -
58
59 """
60
61 import argparse
62 import collections
63 import typing
64 from distutils.version import StrictVersion
65
66 import hpccm
67 import hpccm.config
68 from hpccm.building_blocks.base import bb_base
69
70 try:
71     import utility
72 except ImportError:
73     raise RuntimeError(
74         'This module assumes availability of supporting modules in the same directory. Add the directory to '
75         'PYTHONPATH or invoke Python from within the module directory so module location can be resolved.')
76
77 # Basic packages for all final images.
78 _common_packages = ['build-essential',
79                     'ca-certificates',
80                     'ccache',
81                     'git',
82                     'gnupg',
83                     'libfftw3-dev',
84                     'libhwloc-dev',
85                     'liblapack-dev',
86                     'libx11-dev',
87                     'moreutils',
88                     'ninja-build',
89                     'rsync',
90                     'valgrind',
91                     'vim',
92                     'wget',
93                     'xsltproc']
94
95 # Extra packages needed to build Python installations from source.
96 _python_extra_packages = ['build-essential',
97                           'ca-certificates',
98                           'ccache',
99                           'curl',
100                           'git',
101                           'libbz2-dev',
102                           'libffi-dev',
103                           'liblzma-dev',
104                           'libncurses5-dev',
105                           'libncursesw5-dev',
106                           'libreadline-dev',
107                           'libsqlite3-dev',
108                           'libssl-dev',
109                           'llvm',
110                           'python-openssl',
111                           'vim',
112                           'wget',
113                           'zlib1g-dev']
114
115 # Extra packages needed for images for building documentation.
116 _docs_extra_packages = ['autoconf',
117                         'automake',
118                         'autopoint',
119                         'autotools-dev',
120                         'bison',
121                         'flex',
122                         'ghostscript',
123                         'graphviz',
124                         'help2man',
125                         'imagemagick',
126                         'libtool',
127                         'linkchecker',
128                         'mscgen',
129                         'm4',
130                         'texinfo',
131                         'texlive-latex-base',
132                         'texlive-latex-extra',
133                         'texlive-fonts-recommended',
134                         'texlive-fonts-extra']
135
136 # Supported Python versions for maintained branches.
137 # TODO: Remove '3.5.9' from defaults in master once script in release-2020 diverges.
138 _python_versions = ['3.5.9', '3.6.10', '3.7.7', '3.8.2']
139
140 # Parse command line arguments
141 parser = argparse.ArgumentParser(description='GROMACS CI image creation script', parents=[utility.parser])
142
143 parser.add_argument('--format', type=str, default='docker',
144                     choices=['docker', 'singularity'],
145                     help='Container specification format (default: docker)')
146 parser.add_argument('--venvs', nargs='*', type=str, default=_python_versions,
147                     help='List of Python versions ("major.minor.patch") for which to install venvs. '
148                          'Default: {}'.format(' '.join(_python_versions)))
149
150
151 def base_image_tag(args) -> str:
152     # Check if we use CUDA images or plain linux images
153     if args.cuda is not None:
154         cuda_version_tag = 'nvidia/cuda:' + args.cuda + '-devel'
155         if args.centos is not None:
156             cuda_version_tag += '-centos' + args.centos
157         elif args.ubuntu is not None:
158             cuda_version_tag += '-ubuntu' + args.ubuntu
159         else:
160             raise RuntimeError('Logic error: no Linux distribution selected.')
161
162         base_image_tag = cuda_version_tag
163     else:
164         if args.centos is not None:
165             base_image_tag = 'centos:centos' + args.centos
166         elif args.ubuntu is not None:
167             base_image_tag = 'ubuntu:' + args.ubuntu
168         else:
169             raise RuntimeError('Logic error: no Linux distribution selected.')
170     return base_image_tag
171
172
173 def get_llvm_packages(args) -> typing.Iterable[str]:
174     # If we use the package version of LLVM, we need to install extra packages for it.
175     if (args.llvm is not None) and (args.tsan is None):
176         return ['libomp-dev',
177                 'clang-format-' + str(args.llvm),
178                 'clang-tidy-' + str(args.llvm)]
179     else:
180         return []
181
182
183 def get_compiler(args, compiler_build_stage: hpccm.Stage = None) -> bb_base:
184     # Compiler
185     if args.icc is not None:
186         raise RuntimeError('Intel compiler toolchain recipe not implemented yet')
187
188     if args.llvm is not None:
189         # Build our own version instead to get TSAN + OMP
190         if args.tsan is not None:
191             if compiler_build_stage is not None:
192                 compiler = compiler_build_stage.runtime(_from='tsan')
193             else:
194                 raise RuntimeError('No TSAN compiler build stage!')
195         # Build the default compiler if we don't need special support
196         else:
197             compiler = hpccm.building_blocks.llvm(extra_repository=True, version=args.llvm)
198
199     elif args.gcc is not None:
200         compiler = hpccm.building_blocks.gnu(extra_repository=True,
201                                              version=args.gcc,
202                                              fortran=False)
203     else:
204         raise RuntimeError('Logic error: no compiler toolchain selected.')
205     return compiler
206
207
208 def get_mpi(args, compiler):
209     # If needed, add MPI to the image
210     if args.mpi is not None:
211         if args.mpi == 'openmpi':
212             use_cuda = False
213             if args.cuda is not None:
214                 use_cuda = True
215
216             if hasattr(compiler, 'toolchain'):
217                 return hpccm.building_blocks.openmpi(toolchain=compiler.toolchain, cuda=use_cuda, infiniband=False)
218             else:
219                 raise RuntimeError('compiler is not an HPCCM compiler building block!')
220
221         elif args.mpi == 'impi':
222             # TODO also consider hpccm's intel_mpi package if that doesn't need
223             # a license to run.
224             raise RuntimeError('Intel MPI recipe not implemented yet.')
225         else:
226             raise RuntimeError('Requested unknown MPI implementation.')
227     else:
228         return None
229
230
231 def get_opencl(args):
232     # Add OpenCL environment if needed
233     if (args.opencl is not None):
234         if args.opencl == 'nvidia':
235             if (args.cuda is None):
236                 raise RuntimeError('Need Nvidia environment for Nvidia OpenCL image')
237
238             return hpccm.building_blocks.packages(ospackages=['nvidia-opencl-dev'])
239
240         elif args.opencl == 'intel':
241             return hpccm.building_blocks.packages(
242                     apt_ppas=['ppa:intel-opencl/intel-opencl'],
243                     ospackages=['opencl-headers', 'ocl-icd-libopencl1',
244                                 'ocl-icd-opencl-dev', 'intel-opencl-icd'])
245
246         elif args.opencl == 'amd':
247             # libelf1 is a necessary dependency for something in the ROCm stack,
248             # which they should set up, but seem to have omitted.
249             return hpccm.building_blocks.packages(
250                     apt_keys=['http://repo.radeon.com/rocm/apt/debian/rocm.gpg.key'],
251                     apt_repositories=['deb [arch=amd64] http://repo.radeon.com/rocm/apt/debian/ xenial main'],
252                     ospackages=['ocl-icd-libopencl1', 'ocl-icd-opencl-dev', 'opencl-headers', 'libelf1', 'rocm-opencl'])
253     else:
254         return None
255
256
257 def get_clfft(args):
258     if (args.clfft is not None):
259         return hpccm.building_blocks.generic_cmake(
260             repository='https://github.com/clMathLibraries/clFFT.git',
261             prefix='/usr/local', recursive=True, branch=args.clfft, directory='clFFT/src')
262     else:
263         return None
264
265
266 def add_tsan_compiler_build_stage(input_args, output_stages: typing.Mapping[str, hpccm.Stage]):
267     """Isolate the expensive TSAN preparation stage.
268
269     This is a very expensive stage, but has few and disjoint dependencies, and
270     its output is easily compartmentalized (/usr/local) so we can isolate this
271     build stage to maximize build cache hits and reduce rebuild time, bookkeeping,
272     and final image size.
273     """
274     if not isinstance(output_stages, collections.abc.MutableMapping):
275         raise RuntimeError('Need output_stages container.')
276     tsan_stage = hpccm.Stage()
277     tsan_stage += hpccm.primitives.baseimage(image=base_image_tag(input_args), _as='tsan')
278
279     tsan_stage += hpccm.building_blocks.packages(ospackages=['git', 'ca-certificates', 'build-essential', 'cmake'])
280     # CMake will get duplicated later, but this is an expensive image, and it isn't worth optimizing
281     # out that duplication...
282     tsan_stage += hpccm.building_blocks.python(python3=True, python2=False, devel=False)
283
284     compiler_branch = 'release_' + str(input_args.llvm) + '0'
285     tsan_stage += hpccm.building_blocks.generic_cmake(
286         repository='https://git.llvm.org/git/llvm.git',
287         prefix='/usr/local', recursive=True, branch=compiler_branch,
288         cmake_opts=['-D CMAKE_BUILD_TYPE=Release', '-D LLVM_ENABLE_PROJECTS="clang;openmp;clang-tools-extra"',
289                     '-D LIBOMP_TSAN_SUPPORT=on'],
290         preconfigure=['export branch=' + compiler_branch,
291                       '(cd projects; git clone --depth=1 --branch $branch https://git.llvm.org/git/libcxx.git)',
292                       '(cd projects; git clone --depth=1 --branch $branch https://git.llvm.org/git/libcxxabi.git)',
293                       '(cd projects; git clone --depth=1 --branch $branch https://git.llvm.org/git/compiler-rt.git)',
294                       '(cd ..; git clone --depth=1 --branch $branch https://git.llvm.org/git/openmp.git)',
295                       '(cd ..; git clone --depth=1 --branch $branch https://git.llvm.org/git/clang.git)',
296                       '(cd ..; git clone --depth=1 --branch $branch https://git.llvm.org/git/clang-tools-extra.git)'],
297         postinstall=['ln -s /usr/local/bin/clang++ /usr/local/bin/clang++-' + str(input_args.llvm),
298                      'ln -s /usr/local/bin/clang-format /usr/local/bin/clang-format-' + str(input_args.llvm),
299                      'ln -s /usr/local/bin/clang-tidy /usr/local/bin/clang-tidy-' + str(input_args.llvm),
300                      'ln -s /usr/local/libexec/c++-analyzer /usr/local/bin/c++-analyzer-' + str(input_args.llvm)])
301     output_stages['compiler_build'] = tsan_stage
302
303 def prepare_venv(version: StrictVersion) -> typing.Sequence[str]:
304     """Get shell commands to set up the venv for the requested Python version."""
305     major = version.version[0]
306     minor = version.version[1]
307
308     pyenv = '$HOME/.pyenv/bin/pyenv'
309
310     py_ver = '{}.{}'.format(major, minor)
311     venv_path = '$HOME/venv/py{}'.format(py_ver)
312     commands = ['$({pyenv} prefix `{pyenv} whence python{py_ver}`)/bin/python -m venv {path}'.format(
313         pyenv=pyenv,
314         py_ver=py_ver,
315         path=venv_path
316     )]
317
318     commands.append('{path}/bin/python -m pip install --upgrade pip setuptools'.format(
319         path=venv_path
320     ))
321     # Install dependencies for building and testing gmxapi Python package.
322     # WARNING: Please keep this list synchronized with python_packaging/requirements-test.txt
323     # TODO: Get requirements.txt from an input argument.
324     commands.append("""{path}/bin/python -m pip install --upgrade \
325             'cmake>=3.9.6' \
326             'flake8>=3.7.7' \
327             'mpi4py>=2' \
328             'networkx>=2.0' \
329             'numpy>=1' \
330             'pip>=10.1' \
331             'pytest>=3.9' \
332             'setuptools>=28.0.0' \
333             'scikit-build>=0.7'""".format(path=venv_path))
334
335     return commands
336
337
338 def add_python_stages(building_blocks: typing.Mapping[str, bb_base],
339                       input_args,
340                       output_stages: typing.MutableMapping[str, hpccm.Stage]):
341     """Add the stage(s) necessary for the requested venvs.
342
343     One intermediate build stage is created for each venv (see --venv option).
344
345     Each stage partially populates Python installations and venvs in the home
346     directory. The home directory is collected by the 'pyenv' stage for use by
347     the main build stage.
348     """
349     if len(input_args.venvs) < 1:
350         raise RuntimeError('No venvs to build...')
351     if output_stages is None or not isinstance(output_stages, collections.abc.Mapping):
352         raise RuntimeError('Need a container for output stages.')
353
354     # Main Python stage that collects the environments from individual stages.
355     # We collect the stages individually, rather than chaining them, because the
356     # copy is a bit slow and wastes local Docker image space for each filesystem
357     # layer.
358     pyenv_stage = hpccm.Stage()
359     pyenv_stage += hpccm.primitives.baseimage(image=base_image_tag(input_args), _as='pyenv')
360     pyenv_stage += building_blocks['compiler']
361     pyenv_stage += building_blocks['mpi']
362     pyenv_stage += hpccm.building_blocks.packages(ospackages=_python_extra_packages)
363
364     for version in [StrictVersion(py_ver) for py_ver in sorted(input_args.venvs)]:
365         stage_name = 'py' + str(version)
366         stage = hpccm.Stage()
367         stage += hpccm.primitives.baseimage(image=base_image_tag(input_args), _as=stage_name)
368         stage += building_blocks['compiler']
369         stage += building_blocks['mpi']
370         stage += hpccm.building_blocks.packages(ospackages=_python_extra_packages)
371
372         # TODO: Use a non-root user for testing and Python virtual environments.
373         stage += hpccm.primitives.shell(commands=[
374             'curl https://pyenv.run | bash',
375             """echo 'export PYENV_ROOT="$HOME/.pyenv"' >> $HOME/.bashrc""",
376             """echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> $HOME/.bashrc""",
377             """echo 'eval "$(pyenv init -)"' >> $HOME/.bashrc""",
378             """echo 'eval "$(pyenv virtualenv-init -)"' >> $HOME/.bashrc"""])
379         pyenv = '$HOME/.pyenv/bin/pyenv'
380         commands = ['PYTHON_CONFIGURE_OPTS="--enable-shared" {pyenv} install -s {version}'.format(
381             pyenv=pyenv,
382             version=str(version))]
383         stage += hpccm.primitives.shell(commands=commands)
384
385         commands = prepare_venv(version)
386         stage += hpccm.primitives.shell(commands=commands)
387
388         # TODO: Update user home directory.
389         pyenv_stage += hpccm.primitives.copy(_from=stage_name, _mkdir=True, src=['/root/'],
390                                              dest='/root')
391
392         # Add the intermediate build stage to the sequence
393         output_stages[stage_name] = stage
394
395     # TODO: If we activate pyenv for login shells, the `global` "version" should be full-featured.
396     # # `version` should be a system installation or pyenv environment (or pyenv-virtualenv)
397     # # with the dependencies for all of the Python aspects of CMake-driven builds.
398     # commands = '{pyenv} global {version}'.format(
399     #             pyenv=pyenv,
400     #             version=...)
401     # pyenv_stage += hpccm.primitives.shell(commands=commands)
402
403     # Add the aggregating build stage to the sequence. This allows the main stage to copy
404     # the files in a single stage, potentially reducing the overall output image size.
405     output_stages['pyenv'] = pyenv_stage
406
407
408 def build_stages(args) -> typing.Iterable[hpccm.Stage]:
409     """Define and sequence the stages for the recipe corresponding to *args*."""
410
411     # A Dockerfile or Singularity recipe can have multiple build stages.
412     # The main build stage can copy files from previous stages, though only
413     # the last stage is included in the tagged output image. This means that
414     # large or expensive sets of build instructions can be isolated in
415     # local/temporary images, but all of the stages need to be output by this
416     # script, and need to occur in the correct order, so we create a sequence
417     # object early in this function.
418     stages = collections.OrderedDict()
419
420     # If we need TSAN or oneAPI support the early build is more complex,
421     # so that our compiler images don't have all the cruft needed to get those things
422     # installed.
423     if args.llvm is not None and args.tsan is not None:
424         add_tsan_compiler_build_stage(input_args=args, output_stages=stages)
425
426     # Building blocks are chunks of container-builder instructions that can be
427     # copied to any build stage with the addition operator.
428     building_blocks = collections.OrderedDict()
429
430     # These are the most expensive and most reusable layers, so we put them first.
431     building_blocks['compiler'] = get_compiler(args, compiler_build_stage=stages.get('compiler_build'))
432     building_blocks['mpi'] = get_mpi(args, building_blocks['compiler'])
433
434     # Install additional packages early in the build to optimize Docker build layer cache.
435     os_packages = _common_packages + get_llvm_packages(args)
436     if args.doxygen is not None:
437         os_packages += _docs_extra_packages
438     building_blocks['ospackages'] = hpccm.building_blocks.packages(ospackages=os_packages)
439
440     building_blocks['cmake'] = hpccm.building_blocks.cmake(eula=True, version=args.cmake)
441     building_blocks['opencl'] = get_opencl(args)
442     building_blocks['clfft'] = get_clfft(args)
443
444     # Add Python environments to MPI images, only, so we don't have to worry
445     # about whether to install mpi4py.
446     if args.mpi is not None and len(args.venvs) > 0:
447         add_python_stages(building_blocks=building_blocks, input_args=args, output_stages=stages)
448
449     # Create the stage from which the targeted image will be tagged.
450     stages['main'] = hpccm.Stage()
451
452     stages['main'] += hpccm.primitives.baseimage(image=base_image_tag(args))
453     for bb in building_blocks.values():
454         if bb is not None:
455             stages['main'] += bb
456
457     # We always add Python3 and Pip
458     stages['main'] += hpccm.building_blocks.python(python3=True, python2=False, devel=True)
459     stages['main'] += hpccm.building_blocks.pip(upgrade=True, pip='pip3',
460                                                 packages=['pytest', 'networkx', 'numpy'])
461
462     # Add documentation requirements (doxygen and sphinx + misc).
463     if (args.doxygen is not None):
464         if (args.doxygen == '1.8.5'):
465             doxygen_commit = 'ed4ed873ab0e7f15116e2052119a6729d4589f7a'
466         else:
467             doxygen_commit = 'a6d4f4df45febe588c38de37641513fd576b998f'
468         stages['main'] += hpccm.building_blocks.generic_autotools(
469             repository='https://github.com/westes/flex.git',
470             commit='f7788a9a0ecccdc953ed12043ccb59ca25714018',
471             prefix='/tmp/install-of-flex',
472             configure_opts=['--disable-shared'],
473             preconfigure=['./autogen.sh'])
474         stages['main'] += hpccm.building_blocks.generic_autotools(
475             repository='https://github.com/doxygen/doxygen.git',
476             commit=doxygen_commit,
477             prefix='',
478             configure_opts=[
479                 '--flex /tmp/install-of-flex/bin/flex',
480                 '--static'],
481             postinstall=[
482                 'sed -i \'/\"XPS\"/d;/\"PDF\"/d;/\"PS\"/d;/\"EPS\"/d;/disable ghostscript format types/d\' /etc/ImageMagick-6/policy.xml'])
483         stages['main'] += hpccm.building_blocks.pip(pip='pip3', packages=['sphinx==1.6.1'])
484
485     if 'pyenv' in stages and stages['pyenv'] is not None:
486         stages['main'] += hpccm.primitives.copy(_from='pyenv', _mkdir=True, src=['/root/.pyenv/'],
487                                                 dest='/root/.pyenv')
488         stages['main'] += hpccm.primitives.copy(_from='pyenv', _mkdir=True, src=['/root/venv/'],
489                                                 dest='/root/venv')
490         # TODO: Update user home directory.
491         # TODO: If we activate pyenv for login shells, the `global` "version" should be full-featured.
492         # stages['main'] += hpccm.primitives.copy(_from='pyenv', src=['/root/.bashrc'],
493         #                                         dest='/root/')
494
495     # Note that the list of stages should be sorted in dependency order.
496     for build_stage in stages.values():
497         if build_stage is not None:
498             yield build_stage
499
500
501 if __name__ == '__main__':
502     args = parser.parse_args()
503
504     # Set container specification output format
505     hpccm.config.set_container_format(args.format)
506
507     container_recipe = build_stages(args)
508
509     # Output container specification
510     for stage in container_recipe:
511         print(stage)