Environment Modules with Lmod: How `module load` Actually Works
Every HPC user has typed module load gcc/13.2.0 openmpi/5.0.1 and watched their compiler, MPI library, and a dozen environment variables magically rearrange themselves. It looks like black magic the first time. It is not. It is a reasonably thin Lua script manipulating shell environment variables — and once you understand the mechanism, you can build a software tree that serves hundreds of users with conflicting toolchain requirements and no filesystem collisions.
This post is about Lmod, the Lua-based environment modules system that has largely replaced the older TCL Environment Modules on HPC clusters. We will cover how module loading actually manipulates your shell, how hierarchical modules let you express “OpenMPI built against this GCC version,” how to build a maintainable module tree, how collections and restoring sessions works, and the pitfalls that every site hits.
Why environment modules exist
Software on an HPC cluster has a hard problem to solve. You have:
- Multiple compilers (GCC 9, 11, 13, Intel Classic, Intel OneAPI, NVIDIA HPC, AMD AOCC).
- Multiple MPI implementations (OpenMPI 4.x, 5.x, MPICH, Intel MPI, MVAPICH2) each needing to be built against each compiler.
- Multiple scientific libraries (FFTW, HDF5, NetCDF, PETSc, Trilinos) each needing to be built against each compiler + MPI combination.
- Multiple versions of each, because one research group wants GCC 9 for reproducibility and another wants GCC 13 for C++23 features.
If you install all of these into /usr/local, paths collide, LD_LIBRARY_PATH becomes a monster, and users cannot pick a coherent toolchain. The solution is to install everything in isolated prefixes and provide a mechanism to set PATH, LD_LIBRARY_PATH, MANPATH, PKG_CONFIG_PATH, and CPATH for a chosen combination on demand. That mechanism is environment modules.
The mechanism: how module load actually works
module is a shell function (or alias) — not a binary. When you run module load gcc/13.2.0, here is the sequence:
- The
modulefunction calls the Lmod executable (lmodin Lua, typically/usr/share/lmod/lmod/libexec/lmod), passing it the action (load), the arguments, and the current shell type. - Lmod finds the module file — typically
.luaor TCL — in one of the directories inMODULEPATH. - Lmod executes the module file in a sandboxed Lua environment, where functions like
prepend_path,setenv, andloadare defined. The module file does not itself modify the shell; it tells Lmod what it wants to do. - Lmod collects all the requested changes (push
/opt/gcc/13.2.0/binontoPATH, setCC=gcc, etc.) and emits shell-specific code (e.g.,export PATH=/opt/gcc/13.2.0/bin:$PATH; export CC=gcc;) to stdout. - The
moduleshell function evaluates that output. That is the critical step — Lmod cannot modify your shell directly; it can only print export statements that your shell evaluates.
You can see this yourself:
|
|
Unload reverses the process. Lmod tracks which paths were prepended by which modules (in _ModuleTable_ inside LOADEDMODULES bookkeeping) and removes exactly those entries. This is why you should never manually edit PATH to add directories a module owns — the unload will not find them to remove.
A minimal module file
Here is a real, minimal Lua module file for GCC 13.2.0:
|
|
Functions worth knowing:
prepend_path("VAR", "value")— prepend to a colon-separated path, avoiding duplicates.append_path— append instead.remove_path— opposite of prepend.setenv("VAR", "value")— set a variable outright.unsetenv("VAR")— remove.pushenv("VAR", "value")— set a variable but remember the previous value;popenvrestores it.load("otherModule")— load another module as a dependency.depends_on("otherModule")— load if not loaded; track as a dependency. If the module is alsodepends_onby several other modules, it does not unload until the last of them unloads. This is the dependency-counting feature that makes clean module trees possible.conflict("openmpi")— prevent loading if another module conflicts.family("compiler")— any two modules with the samefamilyare mutually exclusive; loading a second one auto-unloads the first. Standard families:compiler,mpi,blas.prereq("gcc"),prereq_any("gcc", "intel")— require that another module is loaded first.help(...)— shown bymodule help <name>.whatis(...)— indexed formodule whatisandmodule spider.
The MODULEPATH
Lmod finds modules by searching directories in MODULEPATH. A minimal site looks like:
/opt/modulefiles/Core
Inside Core, each module is a directory containing version files:
/opt/modulefiles/Core/
├── gcc/
│ ├── 11.4.0.lua
│ ├── 12.3.0.lua
│ ├── 13.2.0.lua
│ └── .modulerc.lua -- optional: aliases, defaults
├── python/
│ ├── 3.11.6.lua
│ └── 3.12.1.lua
└── cmake/
├── 3.27.8.lua
└── 3.28.3.lua
.modulerc.lua in a module directory sets defaults and aliases:
|
|
Now module load gcc resolves to gcc/13.2.0.
Hierarchical modules: the killer feature
Here is the problem flat modules do not solve well: users expect to load gcc, then openmpi, and get an OpenMPI built against that GCC. If OpenMPI is flat, you have to name modules openmpi/5.0.1-gcc13.2.0, openmpi/5.0.1-gcc12.3.0, openmpi/5.0.1-intel2024.0 — and users have to remember to match versions.
Hierarchical modules solve this by stacking MODULEPATH entries when you load a compiler. Structure:
/opt/modulefiles/
├── Core/ # Compilers, compiler-independent tools
│ ├── gcc/13.2.0.lua
│ ├── intel/2024.0.0.lua
│ └── cmake/3.28.3.lua
├── Compiler/ # Built against a specific compiler
│ ├── gcc/13.2.0/
│ │ ├── openmpi/5.0.1.lua
│ │ ├── openblas/0.3.26.lua
│ │ └── fftw/3.3.10.lua
│ └── intel/2024.0.0/
│ └── openmpi/5.0.1.lua
└── MPI/ # Built against a specific compiler + MPI
├── gcc/13.2.0/openmpi/5.0.1/
│ ├── hdf5/1.14.3.lua
│ ├── netcdf-c/4.9.2.lua
│ └── petsc/3.20.5.lua
└── intel/2024.0.0/openmpi/5.0.1/
└── hdf5/1.14.3.lua
The key: when gcc/13.2.0 is loaded, it calls prepend_path("MODULEPATH", "/opt/modulefiles/Compiler/gcc/13.2.0") — which makes the compiler-specific modules visible. When openmpi/5.0.1 is loaded (from that directory), it prepends /opt/modulefiles/MPI/gcc/13.2.0/openmpi/5.0.1.
The compiler module:
|
|
The MPI module:
|
|
Now users do:
|
|
And automatically get GCC 13.2.0-built OpenMPI and an HDF5 built for that GCC+OpenMPI combination. Switch compilers:
|
|
Lmod notices that the compiler family changed, unloads everything that was loaded from below the old gcc/13.2.0 MODULEPATH, then tries to reload everything with the new compiler’s modules. If a replacement exists, it loads it; if not, it warns you. This is one of Lmod’s strongest features — the TCL environment modules do not do this.
module spider: the documentation users actually use
module avail shows what is loadable given the current MODULEPATH. In a hierarchical setup this is insufficient — HDF5 is not “available” until you load a compiler and MPI. module spider walks the whole tree:
|
|
The last one is gold. It prints:
You will need to load all module(s) on any one of the lines below
before the "hdf5/1.14.3" module is available to load.
gcc/13.2.0 openmpi/5.0.1
intel/2024.0.0 openmpi/5.0.1
intel/2024.0.0 intel-mpi/2021.11
This is the “what do I need to load?” answer users want, and it comes for free from the hierarchy.
Collections: save your working environment
Users often build up a specific combination of modules they use daily. Collections let them save it:
|
|
module save (no name) saves as default. Restoring the default happens automatically at login if LMOD_SYSTEM_DEFAULT_MODULES does not override it.
Collections live in ~/.lmod.d/ as plain text files. Committing them to a dotfile repo works well.
User-level modules
Users can add their own modules:
|
|
module use PATH prepends to MODULEPATH. module use -a PATH appends. module unuse PATH removes.
A common pattern: team shared modules live at /shared/<group>/modulefiles, and a group login script does module use /shared/<group>/modulefiles automatically.
Properties and colored display
Properties tag modules with attributes. Users filter by them. Common:
|
|
Colors are configured in /etc/lmod/lmodrc.lua:
|
|
Now module avail shows a (G) next to GPU modules in green. Small touch, but users notice.
Using Lmod with Spack or EasyBuild
Nobody writes module files by hand at scale. Spack (spack install) and EasyBuild (eb) both generate Lmod-compatible module files automatically:
|
|
Both generate hierarchical module trees by default and the output drops straight into MODULEPATH. If you are building a software tree from scratch today, use one of them — do not hand-write module files for everything. This post covers the mechanism so you can read, debug, and customize what they generate.
Startup performance: the spider cache
With 500 modules in a hierarchy, module avail can get slow — Lmod has to walk directories and parse files. The fix is the spider cache:
|
|
Lmod will use the cache instead of walking the tree. Users see avail in ~100 ms instead of a few seconds. Run this hourly via cron or after every module tree change.
# /etc/cron.d/lmod-cache
*/15 * * * * root /opt/lmod/update_lmod_system_cache_files -d /opt/lmod/cache /opt/modulefiles
Module naming conventions: choose early, hold forever
Once users have scripts with module load foo/1.2.3 in them, renaming foo breaks everything. Some conventions that have aged well:
- Lowercase module names.
gcc, notGCC.openmpi, notOpenMPI. - Version strings match upstream. GCC’s version is
13.2.0, not13.2. FFTW is3.3.10. PETSc is3.20.5. Never abbreviate —3.2is ambiguous. - Category prefixes are optional and dangerous.
bio/samtools/1.19looks nice until you decidesamtoolsis not really “bio.” Prefer flat naming. - Variants in the version, not the name. If you have a debug build:
openmpi/5.0.1-debug, notopenmpi-debug/5.0.1. - Keep old versions around. Papers cite specific software versions. Nuking
gcc/9.3.0because “it is old” will break irreproducible research six months later.
Debugging Lmod
module --debug is your friend. So is LMOD_TRACING=yes:
|
|
Prints every step of the resolution. Reveals why a module is found, why it is not found, and which MODULEPATH provided the match.
If a module is not visible that should be:
module useit explicitly — is theMODULEPATHentry there?module spider— does Lmod see the module at all?- Check the Lua file for syntax errors —
lua modulefile.luawill not quite work (Lmod injects its functions), butlmod bash load modulefile.luawith verbosity will flag parse errors.
Common errors:
- “Lmod has detected the following error” with a Lua traceback — usually a typo in the module file; the line number is accurate.
- “A conflicting module” — two modules from the same
familyboth want to load. Unload one. - Module loads but binaries are not found — double-check the path in
prepend_path. Usemodule show <name>to see exactly what the module is doing. - Path not unloaded on
module unload— someone manually added an entry toPATHthat the module did not own. Only modules should own their paths.
Other things that surprise newcomers
moduleis not available in all shells by default. For login shells,/etc/profile.d/lmod.shsets up themodulefunction. For non-login non-interactive shells (like a SLURM batch script with#!/bin/bash— not-l), you need to source the init script yourself, or use#!/bin/bash -l, ormodule use $LMOD_SYSTEM_DEFAULT_MODULES.module loadin a script does not survive into child shells unless the child shell re-sourceslmod.sh. This is why SLURM job scripts typically start withsource /etc/profile.d/lmod.sh(or-lon the shebang) before loading modules.- Lmod runs as your user — it has no special privileges. Module files are owned by root typically, but the Lua runs as you, so do not trust unsafe code in user-provided module files on shared systems.
module purgeunloads everything, then re-loads the sticky modules — those listed inLMOD_SYSTEM_DEFAULT_MODULES. Useful for reset-to-clean workflows.module purge --forceskips the sticky list and really unloads everything.- Pinning a version:
module load gcc/13.2.0is pinned;module load gccpicks the default. Advise users to pin in reproducible scripts.
A site architecture sketch
If you are building a module system from scratch for a moderately sized cluster, here is a durable starting point:
/opt/software/ # installed software (Spack-managed)
gcc/13.2.0/...
intel/2024.0.0/...
gcc/13.2.0/openmpi/5.0.1/...
gcc/13.2.0/openmpi/5.0.1/hdf5/1.14.3/...
/opt/modulefiles/ # Lmod MODULEPATH root
Core/
gcc/
intel/
cmake/
git/
Compiler/gcc/13.2.0/
openmpi/
openblas/
fftw/
Compiler/intel/2024.0.0/
openmpi/
intel-mpi/
MPI/gcc/13.2.0/openmpi/5.0.1/
hdf5/
petsc/
MPI/intel/2024.0.0/openmpi/5.0.1/
hdf5/
/opt/lmod/cache/ # spider cache
/etc/lmod/lmodrc.lua:
|
|
/etc/profile.d/z00_lmod.sh (ordered z00_ so it runs last):
|
|
StdEnv is a module users rely on as the baseline — it can do nothing, or it can set sensible defaults (prepend_path("PATH", "/opt/tools/bin")).
Wrapping up
Lmod is small, legible, and surprisingly deep. The basic mechanism — Lua-defined changes emitted as shell code — takes a few minutes to understand. The hierarchical layout, with compiler and MPI families stacking MODULEPATH entries, is the piece that makes a serious cluster maintainable: users think in toolchains, not in ad-hoc version suffixes.
Do not write module files by hand at scale. Let Spack or EasyBuild generate them. But do understand the mechanism, because the day you need to debug why module swap gcc intel does not reload HDF5 the way users expect, or why the spider cache is stale, or why a user’s $PATH has a dangling entry from something they unloaded three hours ago — you will need to know what is happening underneath, and almost none of that is in the module man page.
Comments