Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add Generation module naming scheme #3547

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from

Conversation

eylenth
Copy link

@eylenth eylenth commented Jan 14, 2021

I have created an new customized module naming scheme: GenerationModuleNamingScheme.
it is an implementation of different generation modules using release dates.

An example:
GCCcore-10.2.0, GCC-10.2.0 and foss-2020b will be installed in one specific modulepath: releases/2020b
GCCcore-7.3.0, GCC-7.3.0-2.30, foss-2018b will be installed in another modulepath: releases/2018b

A module available looks like this:

------------------------------------------------------------------- /tools/eb/modules/all/releases/2018b --------------------------------------------------------------------
   80merslidingwindow/1                                    MountRainier/2016.06.05                       breeding_R/3.6.2
   ABySS/2.0.2                                             MultiQC/1.7-Python-3.6.6                      canu/1.8-Perl-5.28.0
   AGAT/0.2.3                                              OpenCV/3.4.1-Python-3.6.6                     canu/2.1.1-Perl-5.28.0                       (D)
   AGAT/0.5.1                                       (D)    OrthoMCL/2.0.9-Perl-5.28.0                    conget_python/1-Python-3.6.6

------------------------------------------------------------------- /tools/eb/modules/all/releases/2016b --------------------------------------------------------------------
   AMOS/3.1.0                      GATK/4.1.0.0-Python-3.6.6 (D)    Minimac3/2.0.1          SAMtools/1.3                                    biogrid_python/3.5.2
   AUGUSTUS/3.2.3-Python-2.7.12    HTSlib/1.4.1                     OpenMPI/1.10.3          ScaLAPACK/2.0.2-OpenBLAS-0.2.18-LAPACK-3.6.1    spaln/2.3.1
  BLASR/2.2                       MUMmer/3.23                      R/3.4.1-X11-20160819    biogrid_perl/5.24.0                             vcf-validator/20171211

I have had a meeting with @boegel , and he suggested to create a pull request for this
But we need an optimization in the det_module_subdir class, to get rid of the ugly "if else" structure

Kenneth told us that a table lookup can be implemented.

@boegel boegel added this to the 4.x milestone Jan 20, 2021
Copy link
Member

@boegel boegel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eylenth The main thing missing here is a test that covers this new module naming scheme.

Are you up for looking into that?

We already have similar tests in test/framework/module_generator.py, see for example test_hierarchical_mns.

It would be good to have a test in place first, before we start rewording generation_mns.py...

elif ec['toolchain']['version'] == '8.3.0':
release_date = '2019b'
elif ec['toolchain']['version'] == '10.2.0':
release_date = '2020b'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you mentioned, we can do a lot better here, although we would need to rely on easyconfig files (for the foss toolchain, in this case) being available then...

Perhaps it's not too terrible after all to hardcode this here, but then we need to maintain it when new common toolchain versions are defined, of course.

If we do so, this should be improved so we have a constant on top that defines the relation between GCC/GCCcore version and (common) toolchain version, rather than copy-pasting things and leaving gaps (like not covering 2020a).

@boegel boegel modified the milestones: 4.x, release after 4.3.3 Feb 14, 2021
@eylenth
Copy link
Author

eylenth commented Feb 18, 2021

I could give it a try to write tests in test/framework/module_generator.py
but this will be a time consuming process for me.
to be honest, I see that test/framework/module_generator.py have 13 contributors, and I think they would write tests for the generation_mns much faster than me.
If someone else has time to write tests for the generation_mns, that woul be appreciated.

@eylenth
Copy link
Author

eylenth commented Feb 26, 2021

I have asked a python developer from another team at our concern to look into the unit tests

Thomas Soenen and others added 2 commits May 17, 2021 18:05
@tsoenen
Copy link

tsoenen commented May 18, 2021

@boegel

I've added a lookup table to map toolchain versions to a specific generation. The entries in the table are based on the easyconfigs that are available in the easybuild-easyconfig repo.

Based on the data on this page (https://docs.easybuild.io/en/latest/Common-toolchains.html#overview-of-common-toolchains), I updated the lookup table, but as you can see, it is still incomplete ('TBD' value for those versions that I couldn't determine the generation for). I'm a bit out of my depth here when it comes to completing the lookup table. Can you suggest something/someone to fill out the missing parts?

Any suggestion on how to keep this lookup table configurable, so that users can add versions from their custom easyconfigs? Maybe an ENV that points to a dictionary that the lookup table should be updated with?

To make sure that the lookup table is always in sync with the easybuild-easyconfig repository (so that all used versions are mapped to a generation), I'd like to add a test that checks/enforces this. But since this test evaluates a link between two separate repositories, I'm not sure where this test should go.

@boegel
Copy link
Member

boegel commented May 19, 2021

@tsoenen I think the better way forward here is to not have a lookup table at all. That avoids having to maintain it, and it also allows for this module naming scheme to work with custom toolchains that may not be included in the central easyconfigs repository.

The alternative is to let EasyBuild figure out itself what the correct mapping is for the current "active" toolchain. That'll require that the corresponding easyconfigs for the toolchain are available to EasyBuild, but that's more or less the case already anyway for other reasons.

I promised @eylenth to look into this, but I haven't found time yet for that.

@boegel boegel modified the milestones: 4.4.2, release after 4.4.2 Sep 1, 2021
@boegel boegel modified the milestones: 4.5.0, 4.x Oct 13, 2021
@tsoenen
Copy link

tsoenen commented Nov 23, 2021

@boegel Any updates on this? How should we proceed?

@boegel
Copy link
Member

boegel commented Nov 24, 2021

@tsoenen My apologies for not getting back to this yet, it's been difficult to find enough time...

There's a couple of things that should happen here:

  • fix the code style problems that were reported by the Hound CI
  • avoid the static lookup table (see previous comment)

The latter should basically boil down to leveraging the get_toolchain_hierarchy function:

>>> from easybuild.tools.options import set_up_configuration
>>> set_up_configuration()
>>> from easybuild.framework.easyconfig.easyconfig import get_toolchain_hierarchy
>>> get_toolchain_hierarchy({'name': 'foss', 'version': '2021a'})
[{'name': 'GCCcore', 'version': '10.3.0'}, {'name': 'GCC', 'version': '10.3.0'}, {'name': 'gompi', 'version': '2021a'}, {'name': 'foss', 'version': '2021a'}]

Is this something you're up for looking into yourself?

@tsoenen
Copy link

tsoenen commented Nov 24, 2021

@boegel Thanks for the feedback. I'll take a stab at it and reach out if I'm blocked.

@tsoenen
Copy link

tsoenen commented Dec 13, 2021

@boegel One question related to the use of get_toolchain_hierarchy(): how can I use it to determine a release date for a e.g. binutils-2.25-GCCcore-4.9.3? The ec file shows toolchain = {'name': 'GCCcore', 'version': '4.9.3'}, so running

get_toolchain_hierarchy({'name': 'GCCcore', 'version': '4.9.3'})

gives

[{'name': 'GCCcore', 'version': '4.9.3'}]

How do I go from there to e.g. 2018b, without using a lookup table?

@ocaisa
Copy link
Member

ocaisa commented Dec 13, 2021

I would generate the look-up table dynamically using the top-level toolchains foss/2021b, foss/2021a, etc. as input for get_toolchain_hierarchy.

@tsoenen
Copy link

tsoenen commented Dec 14, 2021

@ocaisa: thanks for the response. This gets me a bit further, but not all the way there.

For example, this is part of the dynamic lookup table:

2016a:
- name: GCCcore
  version: 4.9.3
- name: GCC
  version: 4.9.3-2.25
- name: gompi
  version: 2016a
- name: foss
  version: 2016a
2016b:
- name: GCCcore
  version: 5.4.0
- name: GCC
  version: 5.4.0-2.26
- name: gompi
  version: 2016b
- name: foss
  version: 2016b

GCCcore also has a 5.3.0 version. How should I resolve that one? Should I assume that since 5.4.0 belongs to 2016b, all versions between 4.9.3 and 5.4.0 belong to 2016a?

@ocaisa
Copy link
Member

ocaisa commented Dec 14, 2021

You are destined to run into these issues because not everything fits neatly into the proposed naming scheme. What happens if the same GCC version is used in two generations?

@tsoenen
Copy link

tsoenen commented Dec 16, 2021

I did a quick scan of the easyconfig files in the test directory, and almost all of them can be mapped on a GCC or GCCcore version (those that can't be mapped are {'CrayGNU', 'GNU', 'CrayCCE', 'CrayIntel'}).

GCC and GCCcore seem to release more versions than there are generations. Does it occur that the same GCC version is used in multiple generations?

If we have a one to one mapping between GCC / GCCcore versions and generations (e.g. by mapping 'in between' versions on the most recent generation of its older versions), it seems like we are almost there.

Do you think this is feasible (I'm a little out of my depth here)?

@boegel
Copy link
Member

boegel commented Jan 14, 2022

I did a quick scan of the easyconfig files in the test directory, and almost all of them can be mapped on a GCC or GCCcore version (those that can't be mapped are {'CrayGNU', 'GNU', 'CrayCCE', 'CrayIntel'}).

GCC and GCCcore seem to release more versions than there are generations.

That's correct, we add easyconfigs for every GCC release, but they may not end up actually being used in a (common) toolchain as base compiler.

Does it occur that the same GCC version is used in multiple generations?

No, every generation only has a single GCC(core) version; see https://docs.easybuild.io/en/latest/Common-toolchains.html#component-versions-in-foss-toolchain.
Unless I missed something? @ocaisa?

If we have a one to one mapping between GCC / GCCcore versions and generations (e.g. by mapping 'in between' versions on the most recent generation of its older versions), it seems like we are almost there.

Can you give a concrete example of what you mean?

Do you think this is feasible (I'm a little out of my depth here)?

I'm not sure, I'm starting to think that this module naming scheme can only work for a subset of toolchains, not everything.

One other option is to just opt-out in case you run into something you can't map, with a meaningful error message?
Perhaps a combo of a lookup table generated based on get_toolchain_hierarchy + a fallback mechanism with a hardcoded lookup table can be a way forward?

@tsoenen
Copy link

tsoenen commented Jan 19, 2022

@boegel Thx for the feedback

If we have a one to one mapping between GCC / GCCcore versions and generations (e.g. by mapping 'in between' versions on the most recent generation of its older versions), it seems like we are almost there.

Can you give a concrete example of what you mean?

GCC 4.9.3 maps on generation 2016a and 5.4.0 maps on generation 2016b. I'm proposing to map everything between 4.9.3 and 5.4.0, e.g. 5.3.0 on 2016a as well. This would consider that the mapping shown here (https://docs.easybuild.io/en/latest/Common-toolchains.html#component-versions-in-foss-toolchain) indicates first GCC version of a new generation, and every older GCC version part of an older generation.

Do you think this is feasible (I'm a little out of my depth here)?

I'm not sure, I'm starting to think that this module naming scheme can only work for a subset of toolchains, not everything.

One other option is to just opt-out in case you run into something you can't map, with a meaningful error message? Perhaps a combo of a lookup table generated based on get_toolchain_hierarchy + a fallback mechanism with a hardcoded lookup table can be a way forward?

Does this imply that the error message should list all the toolchains for which the generated mapping can't provide a generation, allowing the user to provide a hardcoded mapping for those toolchains in a file, which can then be attached when calling the namingscheme code?

@tsoenen
Copy link

tsoenen commented Feb 7, 2022

Hey @boegel , when you have time, could you provide some feedback on my last message?

Thomas Soenen and others added 2 commits May 3, 2022 18:34
self.lookup_table = {}

# Get all generations
foss_filenames = search_easyconfigs("^foss-20[0-9]{2}[a-z]\.eb",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalid escape sequence '.'

@tsoenen
Copy link

tsoenen commented May 4, 2022

Hi @boegel and @ocaisa. I've continued with this effort, and updated the code so that the mapping between generations and toolchains happens dynamically. The objective of this PR is to introduce a new module naming scheme that is based on the foss-generation that the toolchain belongs to (e.g. the full path could be something like releases/2018a/MountRainier/2016.06.05). Please ignore the hardcoded lookup table that is still in the PR, I forgot to remove it with this update. To be able to continue with this effort, I have a couple of issues I'd like to discuss:

  1. To build this mapping, I've used the get_toolchain_hierarchy() function, as suggested by you guys above. This introduced a problem: get_toolchain_hierarchy() uses the toolchain() property in framework/easyconfig/easyconfig.py which contains the following snippet at line 1192/1193:
self._toolchain = get_toolchain(self['toolchain'], self['toolchainopts'], mns=ActiveMNS(), tcdeps=tcdeps, modtool=self.modules_tool)

Since ActiveMNS() creates another GenerationModuleNamingScheme object, which again calls get_toolchain_hierarchy(), this introduces a circular reference which eventually leads to a recursion error. I'm a bit out of my depth here as to what is to correct solution for this problem. Is there another way I can use to find toolchains hierarchy? Should get_toolchain_hierarchy() be depending on ActiveMNS(), or should it just default to the Hierarchical MNS?

  1. I've added the option for the user to add to/override the dynamic mapping through a file. The file path should be provided through the GENERATION_MODULE_NAMING_SCHEME_LOOKUP_TABLE ENV and the content of the file is checked on formatting before it is being used. Is this an acceptable approach to deal with toolchains that can't be mapped on a generation?

  2. The current implementation can map more or less 95% of the easyconfigs available in the public repository on a generation. This could be even higher (close to 100%) if we have a proper approach to map "in between" GCC and GCCCore versions on a generation as well. For example, GCC version 4.9.3 maps to 2016a and version 5.4.0 maps to 2016b. Version 5.3.0 is in between these two versions, and can't be mapped on either generation using get_toolchain_hierarchy(). There are two options here afaik: 1) We leave them unmapped, and it is up to the user to hardcode them using the mapping file if they want to, or 2) we map them on the same generation as the most-recent mappable version at the time of release (e.g. for 5.3.0, the most recent mappable version at the time of release was 4.9.3). Any suggestion on the proper way forward?

  3. Currently, the subdir for a system toolchain is general/<> and for a non-system toolchain releases/<generation>/<>. Are these generic enough, or should they be tweakable through an ENV?

@ocaisa
Copy link
Member

ocaisa commented May 16, 2022

I was thinking about this a bit and I wonder if there is not a simpler solution for your use case. For quite a few years now, there has been a GCCcore that underpins the toolchain generations. Filtering the module location based on what GCCcore it (explicitly/implicitly) uses will give you the division that you need. You could then use Lmod to rename the generations as needed when displaying modules.

This approach would remove the need for a lookup table (but kind of export the problem to Lmod). You still have legacy stuff, before we had GCCcore, but that is going back in history a long time.

Unfortunately this doesn't help your recursion error, and I can't see an easy way out of that. I was wondering if you can figure out a way to temporarily change the return value of ActiveMNS?

@ocaisa
Copy link
Member

ocaisa commented May 16, 2022

ActiveMNS() is a class instance which ultimately calls https://github.com/easybuilders/easybuild-framework/blob/develop/easybuild/framework/easyconfig/easyconfig.py#L2538 which just grabs a configuration value (https://github.com/easybuilders/easybuild-framework/blob/develop/easybuild/tools/config.py#L706), so I think you could temporarily change the value of ConfigurationVariables()['module_naming_scheme'] to EasyBuildMNS before you call get_toolchain_hierarchy so you get the GCCcore version.

@ocaisa
Copy link
Member

ocaisa commented May 18, 2022

Here a simplified version that groups modules based on GCCcore (and includes the recursion fix):

"""
Implementation of a different generation specific module naming scheme using release dates.
:author: Thomas Eylenbosch (Gluo N.V.)
:author: Thomas Soenen (B-square IT services)
:author: Alan O'Cais (CECAM)
"""

import os
import json

from easybuild.tools.module_naming_scheme.mns import ModuleNamingScheme
from easybuild.tools.build_log import EasyBuildError
from easybuild.tools.config import ConfigurationVariables
from easybuild.tools.robot import search_easyconfigs
from easybuild.framework.easyconfig.easyconfig import get_toolchain_hierarchy
from easybuild.tools.toolchain.toolchain import is_system_toolchain


class GenerationMNS(ModuleNamingScheme):
    """Class implementing the generational module naming scheme."""

    REQUIRED_KEYS = ['name', 'version', 'versionsuffix', 'toolchain']


    def det_full_module_name(self, ec):
        """
        Determine full module name, relative to the top of the module path.
        Examples: General/GCC/4.8.3, Releases/2018b/OpenMPI/1.6.5
        """
        return os.path.join(self.det_module_subdir(ec), self.det_short_module_name(ec))

    def det_short_module_name(self, ec):
        """
        Determine short module name, i.e. the name under which modules will be exposed to users.
        Examples: GCC/4.8.3, OpenMPI/1.6.5, OpenBLAS/0.2.9, HPL/2.1, Python/2.7.5
        """
        return os.path.join(ec['name'], self.det_full_version(ec))

    def det_full_version(self, ec):
        """Determine full version, taking into account version prefix/suffix."""
        # versionprefix is not always available (e.g., for toolchains)
        versionprefix = ec.get('versionprefix', '')
        return versionprefix + ec['version'] + ec['versionsuffix']

    def det_module_subdir(self, ec):
        """
        Determine subdirectory for module file in $MODULEPATH. This determines the separation
        between module names exposed to users, and what's part of the $MODULEPATH. subdirectory
        is determined by mapping toolchain on a generation.
        """
        release = os.path.join('releases', 'GCCcore')
        release_version = ''
        compiler_subdir = ''
        compiler_subdir_version = ''

        if is_system_toolchain(ec['toolchain']['name']):
            release = 'General'
        else:
            # Give a temporary value for the MNS so we can generate the toolchain hierarchy
            eb_config = ConfigurationVariables()
            setattr(eb_config, 'module_naming_scheme', 'EasyBuildMNS')
            tc_hierarchy = get_toolchain_hierarchy(ec['toolchain'])
            setattr(eb_config, 'module_naming_scheme', 'GenerationMNS')
            for tc in tc_hierarchy:
                if release_version:
                    # This means the GCCcore version has been set (and there is another level in the hierarchy)
                    compiler_subdir = tc['name']
                    compiler_subdir_version = tc['version']
                    break
                if tc['name'] == 'GCCcore':
                    release_version = tc['version']
            if not compiler_subdir:
                compiler_subdir = 'common'
            if release_version == '':
                msg = "Couldn't map software version ({}, {}) to a generation!}"
                raise EasyBuildError(msg.format(ec['name'], ec['version']))

        return os.path.join(release, release_version, compiler_subdir, compiler_subdir_version).rstrip('/')

I think we would still struggle to accept this in EB as it would require clever external modules to make sure the MODULEPATH is set up correctly. Also, it is effectively implementing a standard hierarchy without the MPI level...I'd probably just do that instead.

@ocaisa
Copy link
Member

ocaisa commented May 18, 2022

Here's a version that leverages HierarchicalMNS and just exlcudes adding an MPI layer:

"""
Implementation of an example compiler-only hierarchical module naming scheme.

:author: Kenneth Hoste (Ghent University)
:author: Markus Geimer (Forschungszentrum Juelich GmbH)
"""

import os
import re

from easybuild.toolchains.gcccore import GCCcore
from easybuild.tools.build_log import EasyBuildError
from easybuild.tools.module_naming_scheme.hierarchical_mns import HierarchicalMNS
from easybuild.tools.module_naming_scheme.toolchain import det_toolchain_compilers, det_toolchain_mpi


CORE = 'Core'
COMPILER = 'Compiler'
TOOLCHAIN = 'Toolchain'

MODULECLASS_COMPILER = 'compiler'
MODULECLASS_TOOLCHAIN = 'toolchain'

GCCCORE = GCCcore.NAME

# note: names in keys are ordered alphabetically
COMP_NAME_VERSION_TEMPLATES = {
    # required for use of iccifort toolchain
    'icc,ifort': ('intel', '%(icc)s'),
    'iccifort': ('intel', '%(iccifort)s'),
    # required for use of intel-compilers toolchain (OneAPI compilers)
    'intel-compilers': ('intel', '%(intel-compilers)s'),
    # required for use of ClangGCC toolchain
    'Clang,GCC': ('Clang-GCC', '%(Clang)s-%(GCC)s'),
    # required for use of gcccuda toolchain, and for CUDA installed with GCC toolchain
    'CUDA,GCC': ('GCC-CUDA', '%(GCC)s-%(CUDA)s'),
    # required for use of iccifortcuda toolchain
    'CUDA,icc,ifort': ('intel-CUDA', '%(icc)s-%(CUDA)s'),
    'CUDA,iccifort': ('intel-CUDA', '%(iccifort)s-%(CUDA)s'),
    # required for CUDA installed with iccifort toolchain
    # need to use 'intel' here because 'iccifort' toolchain maps to 'intel' (see above)
    'CUDA,intel': ('intel-CUDA', '%(intel)s-%(CUDA)s'),
    # required for use of xlcxlf toolchain
    'xlc,xlf': ('xlcxlf', '%(xlc)s'),
}

# possible prefixes for Cray toolchain names
# example: CrayGNU, CrayCCE, cpeGNU, cpeCCE, ...;
# important for determining $MODULEPATH extensions in det_modpath_extensions,
# cfr. https://github.com/easybuilders/easybuild-framework/issues/3575
CRAY_TOOLCHAIN_NAME_PREFIXES = ('Cray', 'cpe')


class CompilerHierarchicalMNS(HierarchicalMNS):
    """Class implementing an example hierarchical module naming scheme."""

    def det_module_subdir(self, ec):
        """
        Determine module subdirectory, relative to the top of the module path.
        This determines the separation between module names exposed to users, and what's part of the $MODULEPATH.
        Examples: Core, Compiler/GCC/4.8.3 
        """
        tc_comps = det_toolchain_compilers(ec)
        # determine prefix based on type of toolchain used
        if tc_comps is None:
            # no compiler in toolchain, system toolchain => Core module
            subdir = CORE
        elif tc_comps == [None]:
            # no info on toolchain compiler (cfr. Cray toolchains),
            # then use toolchain name/version
            subdir = os.path.join(TOOLCHAIN, ec.toolchain.name, ec.toolchain.version)
        else:
            tc_comp_name, tc_comp_ver = self.det_toolchain_compilers_name_version(tc_comps)
            subdir = os.path.join(COMPILER, tc_comp_name, tc_comp_ver)

        return subdir

    def det_modpath_extensions(self, ec):
        """
        Determine module path extensions, if any.
        Examples: Compiler/GCC/4.8.3 (for GCC/4.8.3 module)
        """
        modclass = ec['moduleclass']
        tc_comps = det_toolchain_compilers(ec)
        tc_comp_info = self.det_toolchain_compilers_name_version(tc_comps)

        # we consider the following to be compilers:
        # * has 'compiler' specified as moduleclass
        is_compiler = modclass == MODULECLASS_COMPILER
        # * CUDA, but only when not installed with 'system' toolchain (i.e. one or more toolchain compilers found)
        non_system_tc = tc_comps is not None
        non_system_cuda = ec['name'] == 'CUDA' and non_system_tc

        paths = []
        if is_compiler or non_system_cuda:
            # obtain list of compilers based on that extend $MODULEPATH in some way other than <name>/<version>
            extend_comps = []
            # exclude GCC for which <name>/<version> is used as $MODULEPATH extension
            excluded_comps = ['GCC']
            for comps in COMP_NAME_VERSION_TEMPLATES.keys():
                extend_comps.extend([comp for comp in comps.split(',') if comp not in excluded_comps])

            comp_name_ver = None
            if ec['name'] in extend_comps:
                for key in COMP_NAME_VERSION_TEMPLATES:
                    comp_names = key.split(',')
                    if ec['name'] in comp_names:
                        comp_name, comp_ver_tmpl = COMP_NAME_VERSION_TEMPLATES[key]
                        comp_versions = {ec['name']: self.det_full_version(ec)}
                        if ec['name'] == 'ifort':
                            # 'icc' key should be provided since it's the only one used in the template
                            comp_versions.update({'icc': self.det_full_version(ec)})

                        if non_system_tc:
                            tc_comp_name, tc_comp_ver = tc_comp_info
                            # Stick to name GCC for GCCcore
                            if tc_comp_name == GCCCORE:
                                tc_comp_name = 'GCC'
                            if tc_comp_name in comp_names:
                                # also provide toolchain version for non-system toolchains
                                comp_versions.update({tc_comp_name: tc_comp_ver})

                        comp_ver_keys = re.findall(r'%\((\w+)\)s', comp_ver_tmpl)
                        if all(comp_ver_key in comp_versions for comp_ver_key in comp_ver_keys):
                            comp_name_ver = [comp_name, comp_ver_tmpl % comp_versions]
                            break
            else:
                comp_name_ver = [ec['name'], self.det_full_version(ec)]

            if comp_name_ver is None:
                raise EasyBuildError("Required compilers not available in toolchain %s for %s v%s",
                                     ec['toolchain'], ec['name'], ec['version'])

            paths.append(os.path.join(COMPILER, *comp_name_ver))

        # special case for Cray toolchains
        elif modclass == MODULECLASS_TOOLCHAIN and tc_comp_info is None:
            if any(ec.name.startswith(x) for x in CRAY_TOOLCHAIN_NAME_PREFIXES):
                paths.append(os.path.join(TOOLCHAIN, ec.name, ec.version))

        return paths

You can try this out with

eb --include-module-naming=./compiler_hierarchical_mns.py --module-naming="CompilerHierarchicalMNS" SciPy-bundle-2021.10-foss-2021b.eb -D

@tsoenen
Copy link

tsoenen commented May 20, 2022

@ocaisa Thanks for the feedback!

I think we would still struggle to accept this in EB as it would require clever external modules to make sure the MODULEPATH is set up correctly.

I'm not quite following this. I've made it possible for the user to extend the lookup table with custom mappings, to overcome issues with external easyconfigs (But I'm not sure that is the problem you are referencing).

This is the current version that I'm working with:

##
# Copyright 2016-2021 Ghent University
#
# This file is part of EasyBuild,
# originally created by the HPC team of Ghent University (http://ugent.be/hpc/en),
# with support of Ghent University (http://ugent.be/hpc),
# the Flemish Supercomputer Centre (VSC) (https://www.vscentrum.be),
# Flemish Research Foundation (FWO) (http://www.fwo.be/en)
# and the Department of Economy, Science and Innovation (EWI) (http://www.ewi-vlaanderen.be/en).
#
# https://github.com/easybuilders/easybuild
#
# EasyBuild is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation v2.
#
# EasyBuild is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with EasyBuild.  If not, see <http://www.gnu.org/licenses/>.
##
"""
Implementation of a different generation specific module naming scheme using release dates.
:author: Thomas Eylenbosch (Gluo N.V.)
:author: Thomas Soenen (B-square IT services)
:author: Alan O'Cais (CECAM)
"""

import os
import json

from easybuild.tools.module_naming_scheme.mns import ModuleNamingScheme
from easybuild.tools.build_log import EasyBuildError
from easybuild.tools.robot import search_easyconfigs
from easybuild.tools.config import ConfigurationVariables
from easybuild.framework.easyconfig.easyconfig import get_toolchain_hierarchy
from easybuild.tools.toolchain.toolchain import is_system_toolchain

GMNS_ENV = "GENERATION_MODULE_NAMING_SCHEME_LOOKUP_TABLE"

class GenerationModuleNamingScheme(ModuleNamingScheme):
    """Class implementing the generational module naming scheme."""

    REQUIRED_KEYS = ['name', 'version', 'versionsuffix', 'toolchain']

    def __init__(self):
        """
        Generate lookup table that maps toolchains on foss generations. Generations (e.g. 2018a,
        2020b) are fetched from the foss easyconfigs and dynamically mapped on toolchains using
        get_toolchain_hierarchy. The lookup table can be extended by the user by providing a file.

        Lookup table is a dict with toolchain-generation key-value pairs:{(GCC, 4.8.2): 2016a},
        with toolchains resembled as a tuple.

        json format of file with custom mappings:
        {
          "2018b": [{"name": "GCC", "version": "5.2.0"}, {"name": "GCC", "version": "4.8.2"}],
          "2019b": [{"name": "GCC", "version": "5.2.4"}, {"name": "GCC", "version": "4.8.4"}],
        }
        """
        super().__init__()

        self.lookup_table = {}

        # Get all generations
        foss_filenames = search_easyconfigs("^foss-20[0-9]{2}[a-z]\.eb",
                                            filename_only=True,
                                            print_result=False)
        self.generations = [x.split('-')[1].split('.')[0] for x in foss_filenames]

        # get_toolchain_hierarchy() depends on ActiveMNS(), which can't point to 
        # GenerationModuleNamingScheme to prevent circular reference errors. For that purpose, the MNS
        # that ActiveMNS() points to is tweaked while get_toolchain_hierarchy() is used.
        ConfigurationVariables()._FrozenDict__dict['module_naming_scheme'] = 'EasyBuildMNS'

        # map generations on toolchains
        for generation in self.generations:
            for tc in get_toolchain_hierarchy({'name':'foss', 'version':generation}):
                self.lookup_table[(tc['name'], tc['version'])] = generation
            # include (foss, <generation>) as a toolchain aswell
            self.lookup_table[('foss', generation)] = generation

        # Force config to point to other MNS
        ConfigurationVariables()._FrozenDict__dict['module_naming_scheme'] = 'GenerationModuleNamingScheme'

        # users can provide custom generation-toolchain mapping through a file
        path = os.environ.get(GMNS_ENV)
        if path:
            if not os.path.isfile(path):
                msg = "value of ENV {} ({}) should be a valid filepath"
                raise EasyBuildError(msg.format(GMNS_ENV, path))
            with open(path, 'r') as hc_lookup:
                try:
                    hc_lookup_data = json.loads(hc_lookup.read())
                except json.decoder.JSONDecodeError:
                    raise EasyBuildError("{} can't be decoded as json".format(path))
                if not isinstance(hc_lookup_data, dict):
                    raise EasyBuildError("{} should contain a dict".format(path))
                if not set(hc_lookup_data.keys()) <= set(self.generations):
                    raise EasyBuildError("Keys of {} should be generations".format(path))
                for generation, toolchains in hc_lookup_data.items():
                    if not isinstance(toolchains, list):
                        raise EasyBuildError("Values of {} should be lists".format(path))
                    for tc in toolchains:
                        if not isinstance(tc, dict):
                            msg = "Toolchains in {} should be of type dict"
                            raise EasyBuildError(msg.format(path))
                        if set(tc.keys()) != {'name', 'version'}:
                            msg = "Toolchains in {} should have two keys ('name', 'version')"
                            raise EasyBuildError(msg.format(path))
                        self.lookup_table[(tc['name'], tc['version'])] = generation

    def det_full_module_name(self, ec):
        """
        Determine full module name, relative to the top of the module path.
        Examples: General/GCC/4.8.3, Releases/2018b/OpenMPI/1.6.5
        """
        return os.path.join(self.det_module_subdir(ec), self.det_short_module_name(ec))

    def det_short_module_name(self, ec):
        """
        Determine short module name, i.e. the name under which modules will be exposed to users.
        Examples: GCC/4.8.3, OpenMPI/1.6.5, OpenBLAS/0.2.9, HPL/2.1, Python/2.7.5
        """
        return os.path.join(ec['name'], self.det_full_version(ec))

    def det_full_version(self, ec):
        """Determine full version, taking into account version prefix/suffix."""
        # versionprefix is not always available (e.g., for toolchains)
        versionprefix = ec.get('versionprefix', '')
        return versionprefix + ec['version'] + ec['versionsuffix']

    def det_module_subdir(self, ec):
        """
        Determine subdirectory for module file in $MODULEPATH. This determines the separation
        between module names exposed to users, and what's part of the $MODULEPATH. subdirectory
        is determined by mapping toolchain on a generation.
        """
        release = 'releases'
        release_version = ''

        if is_system_toolchain(ec['toolchain']['name']):
            release = 'General'
        else:
            if self.lookup_table.get((ec['toolchain']['name'], ec['toolchain']['version'])):
                release_version = self.lookup_table[(ec['toolchain']['name'], ec['toolchain']['version'])]
            else:
                tc_hierarchy = get_toolchain_hierarchy({'name': ec['toolchain']['name'],
                                                        'version': ec['toolchain']['version']})
                for tc in tc_hierarchy:
                    if self.lookup_table.get((tc['name'], tc['version'])):
                        release_version = self.lookup_table.get((tc['name'], tc['version']))
                        break

            if release_version == '':
                msg = "Couldn't map software version ({}, {}) to a generation. Provide a custom" \
                      "toolchain mapping through {}"
                raise EasyBuildError(msg.format(ec['name'], ec['version'], GMNS_ENV))

        return os.path.join(release, release_version).rstrip('/')

I have noticed that setting the attribute on ConfigurationVariables() is not enough to bypass the circular reference. Therefor, I'm overwriting the FrozenDict directly.

I also noticed that the mapping part / lookup table is absent in the code that you propose, where that is specifically our objective: to obtain a module naming scheme that uses generations. Is there a reason why it is absent in your proposal (e.g. to keep things more generic)?

@tsoenen
Copy link

tsoenen commented Jun 17, 2022

@ocaisa @boegel

My latest version is available in the PR. afaik it covers all the issues that were discussed throughout the thread. It does differ from the version that @ocaisa proposed, which didn't completely cover what we are trying to achieve (explicitly using the generation namings) - unless I'm missing something.

Can you guys provide a judgement on the current version?

self.lookup_table[('foss', generation)] = generation

# Force config to point to other MNS
ConfigurationVariables()._FrozenDict__dict['module_naming_scheme'] = 'GenerationModuleNamingScheme'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this, this points to the same MNS, not a different one.

Determine short module name, i.e. the name under which modules will be exposed to users.
Examples: GCC/4.8.3, OpenMPI/1.6.5, OpenBLAS/0.2.9, HPL/2.1, Python/2.7.5
"""
return os.path.join(ec['name'], self.det_full_version(ec))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe that you can drop the toolchain/version suffix here without exposing yourself to lots of problems. How does this take into account that you can have, for example, OpenMPI with other compilers? Looking at a concrete example:
OpenMPI-4.0.3-GCC-9.3.0.eb
OpenMPI-4.0.3-gcccuda-2020a.eb
OpenMPI-4.0.3-iccifort-2020.1.217.eb
all of these map to the same module file but are clearly not the same, this lack of uniqueness is a big problem as EB will see them all as installed once the module file is created.

It seems that the MNS is by design focussed on foss alone which means it is heavily exposed to problems that may occur when mixing different toolchains (for example fortran module incompatability between Intel/GCC). Even at foss level the MNS relies on there being no shadowing of software with different toolchains (for example, Python with GCCcore and the same version with GCC), this is currently true in recent releases but there is no guarantee that sites themselves respect this.

@tsoenen
Copy link

tsoenen commented Jul 15, 2022

@ocaisa Thanks for the feedback. I understand the uniqueness conflict. We are only using foss in our setup, so it doesn't occur for us. We understand that it can't become part of the easybuild repo like this, and won't be pursuing this PR anymore.

Thx for the feedback throughout the process

@eylenth eylenth closed this Jul 15, 2022
@eylenth eylenth reopened this Nov 2, 2023
@eylenth
Copy link
Author

eylenth commented Nov 2, 2023

I am reopening this PR. I had a talk with @boegel and there could be a workaround to continue with this PR.

@boegel
Copy link
Member

boegel commented Nov 8, 2023

@ocaisa This may be useful in the context of EESSI, where we eventually want a better way of grouping compatible modules...

@boegelbot
Copy link

@eylenth: Tests failed in GitHub Actions, see https://github.com/easybuilders/easybuild-framework/actions/runs/6730938397
Output from first failing test suite run:

======================================================================
ERROR: test_generation_mns (test.framework.module_generator.TclModuleGeneratorTest)
Test generation module naming scheme.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/test/framework/module_generator.py", line 1543, in test_generation_mns
    test_ec(ecfile, *mns_vals)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/test/framework/module_generator.py", line 1523, in test_ec
    ec = EasyConfig(glob.glob(os.path.join(ecs_dir, '*', '*', ecfile))[0])
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 516, in __init__
    self.parse()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 770, in parse
    self.generate_template_values()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1703, in generate_template_values
    self._generate_template_values()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1740, in _generate_template_values
    toolchain = self.toolchain
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1201, in toolchain
    tc_ec = process_easyconfig(tc_ecfile)[0]
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 2085, in process_easyconfig
    ec = EasyConfig(spec, build_specs=build_specs, validate=validate, hidden=hidden)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 516, in __init__
    self.parse()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 770, in parse
    self.generate_template_values()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1703, in generate_template_values
    self._generate_template_values()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1740, in _generate_template_values
    toolchain = self.toolchain
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1206, in toolchain
    mns=ActiveMNS(), tcdeps=tcdeps, modtool=self.modules_tool)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/tools/config.py", line 185, in __call__
    cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 2556, in __init__
    self.mns = avail_mnss[sel_mns]()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/tools/config.py", line 185, in __call__
    cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/tools/module_naming_scheme/generation_mns.py", line 65, in __init__
    super().__init__()
TypeError: super() takes at least 1 argument (0 given)

======================================================================
ERROR: test_generation_mns (test.framework.module_generator.LuaModuleGeneratorTest)
Test generation module naming scheme.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/test/framework/module_generator.py", line 1543, in test_generation_mns
    test_ec(ecfile, *mns_vals)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/test/framework/module_generator.py", line 1523, in test_ec
    ec = EasyConfig(glob.glob(os.path.join(ecs_dir, '*', '*', ecfile))[0])
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 516, in __init__
    self.parse()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 770, in parse
    self.generate_template_values()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1703, in generate_template_values
    self._generate_template_values()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1740, in _generate_template_values
    toolchain = self.toolchain
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1201, in toolchain
    tc_ec = process_easyconfig(tc_ecfile)[0]
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 2085, in process_easyconfig
    ec = EasyConfig(spec, build_specs=build_specs, validate=validate, hidden=hidden)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 516, in __init__
    self.parse()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 770, in parse
    self.generate_template_values()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1703, in generate_template_values
    self._generate_template_values()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1740, in _generate_template_values
    toolchain = self.toolchain
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1206, in toolchain
    mns=ActiveMNS(), tcdeps=tcdeps, modtool=self.modules_tool)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/tools/config.py", line 185, in __call__
    cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 2556, in __init__
    self.mns = avail_mnss[sel_mns]()
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/tools/config.py", line 185, in __call__
    cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
  File "/tmp/98a7461928ee954de0fefdf21694a42ac7ec5b20/lib/python2.7/site-packages/easybuild/tools/module_naming_scheme/generation_mns.py", line 65, in __init__
    super().__init__()
TypeError: super() takes at least 1 argument (0 given)

----------------------------------------------------------------------
Ran 857 tests in 3079.115s

FAILED (errors=2)
ERROR: Not all tests were successful.

bleep, bloop, I'm just a bot (boegelbot v20200716.01)
Please talk to my owner @boegel if you notice me acting stupid),
or submit a pull request to https://github.com/boegel/boegelbot fix the problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants