Skip to content

Commit e0d9b20

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 8b78b90 commit e0d9b20

5 files changed

Lines changed: 355 additions & 11 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: 56 additions & 10 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,10 @@
1112
import sys
1213
import threading
1314
from collections.abc import Callable, MutableMapping, MutableSequence
15+
from contextlib import suppress
16+
from importlib.resources import files as resource_files
1417
from subprocess import check_call, check_output, run # nosec
18+
from tempfile import NamedTemporaryFile
1519
from typing import cast
1620

1721
from cwl_utils.types import CWLDirectoryType, CWLFileType, CWLObjectType
@@ -29,6 +33,7 @@
2933
from .errors import WorkflowException
3034
from .job import ContainerCommandLineJob
3135
from .loghandler import _logger
36+
from .mpi import MPIRequirementName
3237
from .pathmapper import MapperEnt, PathMapper
3338
from .singularity_utils import singularity_supports_userns
3439
from .utils import create_tmp_dir, ensure_non_writable, ensure_writable
@@ -203,7 +208,7 @@ def __init__(
203208
hints: list[CWLObjectType],
204209
name: str,
205210
) -> None:
206-
"""Builder for invoking the Singularty software container engine."""
211+
"""Builder for invoking the Singularity software container engine."""
207212
super().__init__(builder, joborder, make_path_mapper, requirements, hints, name)
208213

209214
@staticmethod
@@ -592,14 +597,55 @@ def create_runtime(
592597
"""Return the Singularity runtime list of commands and options."""
593598
any_path_okay = self.builder.get_requirement("DockerRequirement")[1] or False
594599

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

@@ -665,4 +711,4 @@ def create_runtime(
665711
if container_HOME:
666712
# Restore HOME if we removed it above.
667713
self.environment["HOME"] = container_HOME
668-
return (runtime, None)
714+
return runtime, None

cwltool/singularity_wrapper.sh

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 a variable.
63+
BASELINE_CONTENT=$'\n'"$(cat "$BASELINE_FILE")"$'\n'
64+
65+
# Build new environment variables for Singularity (i.e. ``SINGULARITYENV_KEY=VALUE``).
66+
# Excludes empty variables and variables whose name do not follow POSIX (e.g. some
67+
# Bash environments on HPC clusters such as BSC MareNostrum5, ``BASH_FUNC_module%%=``).
68+
while IFS='=' read -r k v; do
69+
[[ -n "$k" ]] || continue
70+
[[ "$k" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
71+
# If the current env doesn't exist (``! -z``) in the given baseline env (``BASE_ENV``),
72+
# then we want to add it as ``--env`` in singularity.
73+
# Check if the key exists in the BASELINE_CONTENT string in the
74+
# form \n$KEY= (that's why we start the BASELINE and end it with \n).
75+
if [[ ! "$BASELINE_CONTENT" == *$'\n'"$k"=* ]]; then
76+
# Debug
77+
# echo "Adding env var for Singularity command: SINGULARITYENV_$k=$v" >&2
78+
export "SINGULARITYENV_$k=$v"
79+
fi
80+
done < <(printenv)
81+
82+
# Launch the Singularity binary.
83+
exec "$SINGULARITY_BIN" "${@}"

tests/test_mpi.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,31 @@
33
import json
44
import os.path
55
import sys
6+
import tempfile
67
from collections.abc import Generator, MutableMapping
78
from importlib.resources import files
89
from io import StringIO
910
from pathlib import Path
1011
from typing import Any, cast
1112

1213
import pytest
14+
from cwl_utils.types import CWLOutputType
1315
from ruamel.yaml.comments import CommentedMap, CommentedSeq
16+
from schema_salad.avro import schema
1417
from schema_salad.avro.schema import Names
1518
from schema_salad.ref_resolver import file_uri
1619
from schema_salad.utils import yaml_no_ts
1720

1821
import cwltool.load_tool
1922
import cwltool.singularity
2023
import cwltool.udocker
24+
from cwltool.builder import Builder
2125
from cwltool.command_line_tool import CommandLineTool
2226
from cwltool.context import RuntimeContext
2327
from cwltool.main import main
2428
from cwltool.mpi import MpiConfig, MPIRequirementName
25-
29+
from cwltool.stdfsaccess import StdFsAccess
30+
from cwltool.update import INTERNAL_VERSION
2631
from .util import get_data, working_directory
2732

2833

@@ -35,6 +40,8 @@ def test_mpi_conf_defaults() -> None:
3540
assert mpi.env_pass == []
3641
assert mpi.env_pass_regex == []
3742
assert mpi.env_set == {}
43+
assert mpi.shm_dir == "/dev/shm"
44+
assert mpi.shm_enabled is True
3845

3946

4047
def test_mpi_conf_unknownkeys() -> None:
@@ -334,6 +341,120 @@ def test_singularity(schema_ext11: Names) -> None:
334341
assert jr is cwltool.singularity.SingularityCommandLineJob
335342

336343

344+
def _make_fake_singularity() -> str:
345+
tmpdir = tempfile.mkdtemp()
346+
fake_path = Path(tmpdir) / "singularity"
347+
with open(fake_path, "w") as f:
348+
f.write("#!/bin/sh\n")
349+
# It must print the version, as another test calls ``version()``.
350+
f.write("echo 'singularity-ce version 3.11.5'\n")
351+
352+
fake_path.chmod(0o755)
353+
354+
return tmpdir
355+
356+
357+
@pytest.mark.parametrize(
358+
"requirements,shm_enabled,shm_dir,expected_command",
359+
[
360+
([], True, "/dev/shm", "singularity"),
361+
([], False, "/dev/shm", "singularity"),
362+
(
363+
[CommentedMap({"class": MPIRequirementName, "processes": 1})],
364+
True,
365+
"/dev/shm",
366+
"singularity_wrapper.sh",
367+
),
368+
(
369+
[CommentedMap({"class": MPIRequirementName, "processes": 1})],
370+
False,
371+
"/dev/shm",
372+
"singularity_wrapper.sh",
373+
),
374+
],
375+
ids=[
376+
"No requirements, runs singularity, no shared mem used",
377+
"No requirements, runs singularity, no shared mem used",
378+
"MPIRequirement, runs mpirun, shared memory used",
379+
"MPIRequirement, but no shared memory volume used",
380+
],
381+
)
382+
def test_singularity_create_runtime(
383+
requirements: list[MutableMapping[str, CWLOutputType | None]],
384+
shm_enabled: bool,
385+
shm_dir: str,
386+
expected_command: str,
387+
schema_ext11: Names,
388+
monkeypatch: pytest.MonkeyPatch,
389+
) -> None:
390+
"""Tests that"""
391+
runtime_context = RuntimeContext({})
392+
runtime_context.mpi_config.shm_dir = shm_dir
393+
runtime_context.mpi_config.shm_enabled = shm_enabled
394+
builder = Builder(
395+
{},
396+
[],
397+
[],
398+
{},
399+
schema.Names(),
400+
requirements,
401+
[],
402+
{},
403+
None,
404+
None,
405+
StdFsAccess,
406+
StdFsAccess(""),
407+
None,
408+
0.1,
409+
True,
410+
False,
411+
False,
412+
"no_listing",
413+
runtime_context.get_outdir(),
414+
runtime_context.get_tmpdir(),
415+
runtime_context.get_stagedir(),
416+
INTERNAL_VERSION,
417+
"singularity",
418+
)
419+
job = cwltool.singularity.SingularityCommandLineJob(
420+
builder, {}, CommandLineTool.make_path_mapper, requirements=requirements, hints=[], name=""
421+
)
422+
env = dict(os.environ)
423+
# Inject a fake singularity into the $PATH. The reason for this, is that
424+
# the MacOS GitHub Actions job fails when ``job.create_runtime`` gets
425+
# called. Internally, it calls ``is_apptainer_1_1_or_newer``, which uses
426+
# ``version_output = check_output(["singularity", "--version"], text=True).strip()``.
427+
# The command call above will raise an exception (below) and crash pytest.
428+
# ``FileNotFoundError: [Errno 2] No such file or directory: 'singularity'``.
429+
fake_bin_dir = _make_fake_singularity()
430+
with monkeypatch.context() as m:
431+
m.setenv("PATH", fake_bin_dir + os.pathsep + os.environ["PATH"])
432+
env["PATH"] = fake_bin_dir + os.pathsep + env["PATH"]
433+
434+
job.prepare_environment(runtime_context, env)
435+
command, options = job.create_runtime(env, runtime_context)
436+
437+
assert command
438+
assert not options
439+
440+
assert command[0] == expected_command or command[0].endswith(expected_command)
441+
442+
mpi_req, is_req = builder.get_requirement(MPIRequirementName)
443+
444+
def any_contains(haystack: list[str], needle: str) -> bool:
445+
return any(needle in h for h in haystack)
446+
447+
shared_memory_used = any_contains(command, shm_dir)
448+
449+
if mpi_req and is_req:
450+
if shm_enabled:
451+
assert shared_memory_used, "Missing shared memory volume!"
452+
else:
453+
assert not shared_memory_used, "Shared memory volume not supposed to be used!"
454+
else:
455+
assert not shared_memory_used, "Shared memory volume used without MPIRequirement!"
456+
457+
337458
def test_udocker(schema_ext11: Names) -> None:
338459
rc, clt = mk_tool(schema_ext11, ["--udocker"], reqs=[mpiReq, containerReq])
339460
clt._init_job({}, rc)

0 commit comments

Comments
 (0)