Skip to content

Commit 75eb43e

Browse files
committed
Detect MPI with Singularity.
It uses a wrapper script to detect environment variables added by an MPI launcher program such as mpirun or srun, and exports them as SINGULARITYENV_$KEY=$VALUE. Updates the MpiConfig of the MPIRequirement extension to add the shared memory directory, and a flag to enable or disable shared memory with Singularity (on by default). When enabled, it maps a volume for the directory used (default /dev/shm).
1 parent 10cabef commit 75eb43e

4 files changed

Lines changed: 234 additions & 8 deletions

File tree

cwltool/mpi.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ def __init__(
2323
env_pass: list[str] | None = None,
2424
env_pass_regex: list[str] | None = None,
2525
env_set: Mapping[str, str] | None = None,
26+
shm_enabled: bool = True,
27+
shm_dir: str = "/dev/shm", # nosec B108 - required for MPI/shared memory in containers
2628
) -> None:
2729
"""
2830
Initialize from the argument mapping.
@@ -35,6 +37,8 @@ def __init__(
3537
env_pass: []
3638
env_pass_regex: []
3739
env_set: {}
40+
shm_enabled: True
41+
shm_dir: "/dev/shm
3842
3943
Any unknown keys will result in an exception.
4044
"""
@@ -45,6 +49,11 @@ def __init__(
4549
self.env_pass = env_pass or []
4650
self.env_pass_regex = env_pass_regex or []
4751
self.env_set = env_set or {}
52+
self.shm_enabled = shm_enabled
53+
# POSIX only contains functions to handle shared memory, but it does not
54+
# specify the directory to be used, nor if a directory needs to be used
55+
# at all -- ref: https://pubs.opengroup.org/onlinepubs/9699919799/
56+
self.shm_dir = shm_dir
4857

4958
@classmethod
5059
def load(cls: type[MpiConfigT], config_file_name: str) -> MpiConfigT:

cwltool/singularity.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Support for executing Docker format containers using Singularity {2,3}.x or Apptainer 1.x."""
22

3+
import atexit
34
import copy
45
import hashlib
56
import json
@@ -11,7 +12,9 @@
1112
import sys
1213
import threading
1314
from collections.abc import Callable, MutableMapping, MutableSequence
15+
from importlib.resources import files as resource_files
1416
from subprocess import check_call, check_output, run # nosec
17+
from tempfile import NamedTemporaryFile
1518
from typing import cast
1619

1720
from cwl_utils.types import CWLDirectoryType, CWLFileType, CWLObjectType
@@ -29,6 +32,7 @@
2932
from .errors import WorkflowException
3033
from .job import ContainerCommandLineJob
3134
from .loghandler import _logger
35+
from .mpi import MPIRequirementName
3236
from .pathmapper import MapperEnt, PathMapper
3337
from .singularity_utils import singularity_supports_userns
3438
from .utils import create_tmp_dir, ensure_non_writable, ensure_writable
@@ -592,14 +596,57 @@ def create_runtime(
592596
"""Return the Singularity runtime list of commands and options."""
593597
any_path_okay = self.builder.get_requirement("DockerRequirement")[1] or False
594598

595-
runtime = [
596-
"singularity",
597-
"--quiet",
598-
"run" if (is_apptainer_1_1_or_newer() or is_version_3_10_or_newer()) else "exec",
599-
"--contain",
600-
"--ipc",
601-
"--cleanenv",
602-
]
599+
mpi_req, is_req = self.builder.get_requirement(MPIRequirementName)
600+
mpi_enabled = mpi_req and is_req
601+
mpi_config = runtime_context.mpi_config
602+
mpi_env_vars_reference_file_name: str | None = None
603+
runtime: list[str] = []
604+
if mpi_enabled:
605+
# Save current environment variables. The ``singularity_wrapper.sh`` will
606+
# diff it against the env vars produced by mpirun/srun/etc., and use the new
607+
# env vars as SINGULARITYENV_... for Singularity.
608+
with NamedTemporaryFile(mode="w+", delete=False) as f:
609+
for k, v in os.environ.items():
610+
f.write(f"{k}={v}\n")
611+
mpi_env_vars_reference_file_name = f.name
612+
613+
def delete_mpi_baseline_env() -> None:
614+
"""Clean up the MPI baseline environment variables file at exit."""
615+
try:
616+
os.remove(mpi_env_vars_reference_file_name)
617+
except FileNotFoundError:
618+
pass
619+
620+
atexit.register(delete_mpi_baseline_env)
621+
622+
runtime.extend(
623+
[
624+
str(resource_files("cwltool") / "singularity_wrapper.sh"),
625+
mpi_env_vars_reference_file_name,
626+
"singularity",
627+
]
628+
)
629+
else:
630+
runtime.append("singularity")
631+
632+
runtime.extend(
633+
[
634+
"--quiet",
635+
"run" if (is_apptainer_1_1_or_newer() or is_version_3_10_or_newer()) else "exec",
636+
"--contain",
637+
"--ipc",
638+
"--cleanenv",
639+
]
640+
)
641+
if mpi_enabled and mpi_config.shm_enabled:
642+
# MPI implementations like OpenMPI and MPICH use shared memory.
643+
self.append_volume(
644+
runtime,
645+
runtime_context.create_tmpdir(),
646+
mpi_config.shm_dir,
647+
writable=True,
648+
)
649+
603650
if is_apptainer_1_1_or_newer() or is_version_3_10_or_newer():
604651
runtime.append("--no-eval")
605652

cwltool/singularity_wrapper.sh

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# singularity_wrapper.sh
5+
#
6+
# DESCRIPTION
7+
# Wrapper around Singularity/Apptainer for CWL + MPI + Singularity.
8+
#
9+
# This script identifies environment variables added by an MPI launcher
10+
# (e.g. srun, mpirun) and adds these environment variables as Singularity
11+
# environment variables using the format ``SINGULARITYENV_$KEY=$VALUE``.
12+
#
13+
# This allows CWL (which uses ``--cleanenv``) to launch MPI + Singularity.
14+
#
15+
# USAGE
16+
# singularity_wrapper.sh <baseline-env-file> <singularity-bin> <args>
17+
#
18+
# ARGUMENTS
19+
# <baseline-env-file>
20+
# Path to the file containing KEY=VALUE pairs with the baseline env.
21+
#
22+
# <singularity-bin>
23+
# Path to singularity/apptainer executable.
24+
#
25+
# [args...]
26+
# Arguments passed to the singularity binary.
27+
#
28+
# EXAMPLE
29+
# singularity_wrapper.sh env.txt singularity --cleanenv exec image.sif
30+
#
31+
# DEPENDENCIES
32+
# It uses the following binaries:
33+
# - printenv
34+
35+
usage() {
36+
cat >&2 <<EOF
37+
singularity_wrapper.sh
38+
39+
Wrapper around Singularity/Apptainer for CWL + MPI + Singularity.
40+
41+
USAGE:
42+
singularity_wrapper.sh <baseline-env-file> <singularity-bin> [args...]
43+
EOF
44+
exit 1
45+
}
46+
47+
if [[ "${1:-}" == "--help" ]]; then
48+
usage
49+
fi
50+
51+
[[ $# -ge 2 ]] || usage
52+
53+
BASELINE_FILE="$1"
54+
SINGULARITY_BIN="$2"
55+
shift 2
56+
57+
if [[ ! -f "$BASELINE_FILE" ]]; then
58+
echo "Error: baseline env file not found: $BASELINE_FILE" >&2
59+
exit 2
60+
fi
61+
62+
# Read baseline env into an array.
63+
declare -A BASE_ENV
64+
while IFS='=' read -r k v; do
65+
[[ -n "$k" && -n "$v" ]] || continue
66+
BASE_ENV["$k"]="$v"
67+
done < "$BASELINE_FILE"
68+
69+
# Build new environment variables for Singularity (i.e. ``SINGULARITYENV_KEY=VALUE``).
70+
# Excludes empty variables and variables whose name do not follow POSIX (e.g. some
71+
# Bash environments on HPC clusters such as BSC MareNostrum5, ``BASH_FUNC_module%%=``).
72+
while IFS='=' read -r k v; do
73+
[[ -n "$k" ]] || continue
74+
[[ "$k" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
75+
# If the current env doesn't exist (``! -z``) in the given baseline env (``BASE_ENV``),
76+
# then we want to add it as ``--env`` in singularity.
77+
if [[ -z "${BASE_ENV[$k]+x}" ]]; then
78+
# Debug
79+
# echo "Adding env var for Singularity command: SINGULARITYENV_$k=$v" >&2
80+
export "SINGULARITYENV_$k=$v"
81+
fi
82+
done < <(printenv)
83+
84+
# Launch the Singularity binary.
85+
exec "$SINGULARITY_BIN" "${@}"

tests/test_singularity_wrapper.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Tests for the Shell wrapper of the Singularity command.
2+
3+
This script tests a Shell script. This script does not contribute to the
4+
project test coverage (although kcov, or bats+kcov could be used in the
5+
future).
6+
"""
7+
8+
import os
9+
import subprocess
10+
from importlib.resources import files
11+
from textwrap import dedent
12+
from typing import TYPE_CHECKING
13+
14+
import pytest
15+
16+
if TYPE_CHECKING:
17+
from pathlib import Path
18+
19+
20+
@pytest.mark.parametrize(
21+
"args,expected_return_code",
22+
[(["--help"], 1), ([""], 1), (["singularity"], 1)],
23+
ids=[
24+
"Print usage because user passed --help",
25+
"Print usage because of missing args",
26+
"Print usage because not all args provided",
27+
],
28+
)
29+
def test_wrapper_usage(args: list[str], expected_return_code: int) -> None:
30+
"""Test the usage of the Singularity wrapper is printed."""
31+
wrapper = str(files("cwltool") / "singularity_wrapper.sh")
32+
command: list[str] = [wrapper] + args
33+
result = subprocess.run(command, capture_output=True, text=True)
34+
35+
assert result.returncode == expected_return_code
36+
assert "Wrapper around Singularity/Apptainer for CWL + MPI + Singularity" in result.stderr
37+
38+
39+
def test_wrapper_invalid_baseline_env_file() -> None:
40+
"""Test the script fails if the given file is not valid."""
41+
wrapper = str(files("cwltool") / "singularity_wrapper.sh")
42+
command: list[str] = [wrapper, "parangaricutirimicuaro.dat", "foo"]
43+
result = subprocess.run(command, capture_output=True, text=True)
44+
45+
assert result.returncode == 2
46+
assert "file not found" in result.stderr
47+
48+
49+
def test_wrapper_env_vars(tmp_path: "Path") -> None:
50+
"""Test that the wrapper script adds the new environment variables."""
51+
fake_singularity = tmp_path / "fake_singularity"
52+
fake_singularity.write_text(dedent("""\
53+
#!/bin/bash
54+
echo "Fake Singularity script"
55+
env
56+
"""))
57+
fake_singularity.chmod(0o755)
58+
59+
new_env_var = "TEST_WRAPPER_ENV_VARS_INJECTED_VAR"
60+
61+
# Create the baseline environment variables file.
62+
baseline_env = os.environ
63+
assert new_env_var not in baseline_env, "The test needs a new env var!"
64+
baseline = tmp_path / "baseline.env"
65+
baseline.write_text("A=1\nB=2\n")
66+
for k, v in baseline_env.items():
67+
baseline.write_text(f"{k}={v}")
68+
69+
# Now pretend we are mpirun, and we are adding a new env var.
70+
new_env = os.environ.copy()
71+
new_env[new_env_var] = "42"
72+
73+
wrapper = str(files("cwltool") / "singularity_wrapper.sh")
74+
command: list[str] = [wrapper, str(baseline), str(fake_singularity), "--cleanenv"]
75+
76+
result = subprocess.run(command, capture_output=True, text=True, env=new_env)
77+
78+
assert result.returncode == 0
79+
# There, now the wrapper just runs `env`, and the output must
80+
# contain the new environment variable. We know the wrapper
81+
# must have worked because we have thew new variable in the
82+
# output...
83+
assert new_env_var in result.stdout
84+
# And also because we have the new SINGULARITYENV_{new_env_var}!
85+
assert f"SINGULARITYENV_{new_env_var}" in result.stdout

0 commit comments

Comments
 (0)