Containers
Experimental
Container support is experimental. Single-node runs are tested (CPU, NVIDIA/AMD GPU, and multi-rank MPI); multi-node MPI is cluster-specific — see Running on HPC Clusters.
Pre-built Apptainer/Singularity containers let you run THOR without compiling from source.
Quick Start
thor-generic is the recommended image — same SIF runs on CPU, NVIDIA, or AMD GPU.
Registry access
thor-generic and thor-omp are public packages.
Pick the backend at exec time:
# Download
apptainer pull oras://ghcr.io/cbyrohl/thor-generic:dev
# CPU (OpenMP via SSCP JIT)
apptainer run --env ACPP_VISIBILITY_MASK=omp thor-generic_dev.sif config.yaml
# NVIDIA GPU
apptainer run --nv --env ACPP_VISIBILITY_MASK=cuda thor-generic_dev.sif config.yaml
# AMD GPU
apptainer run --rocm --env ACPP_VISIBILITY_MASK=hip thor-generic_dev.sif config.yaml
Set device: in your config YAML to match — cpu, cpu-openmp, or gpu.
Updating an existing image
The :dev tag is updated in place whenever a new build is published, so your local .sif can fall behind. apptainer pull won't overwrite the existing file by default — pass --force:
Updating from Python or thor-tools
The base thor-rt Python package can download and update images for batch runs
and live sessions. Install Apptainer, then run:
# Create or refresh thor-generic_dev.sif in the current directory
thor-tools container update
# List the supported images and their registry URLs
thor-tools container list
# Select the CPU-only image (defaults to thor-omp_dev.sif)
thor-tools container update --image omp
# Optionally choose a local destination
thor-tools container update ./thor-omp.sif --image omp
The same operation is available from Python:
from thor.container import update_image
image = update_image() # returns an absolute pathlib.Path
image = update_image(image="omp") # writes thor-omp_dev.sif
image = update_image("./thor-omp.sif", image="omp")
The commands support these two image variants, defaulting to their development tags:
| Image | Registry source | Purpose |
|---|---|---|
generic (default) |
oras://ghcr.io/cbyrohl/thor-generic:dev |
THOR for CPU, NVIDIA, or AMD GPU |
omp |
oras://ghcr.io/cbyrohl/thor-omp:dev |
CPU-only THOR |
Unknown image names and custom registry URLs are not accepted. Listing shows the supported catalog; it does not query registry availability or access permissions.
The destination's parent directory must already exist. An existing regular file is replaced; symlinks and directories are rejected. Updates download into a temporary directory beside the destination, check the downloaded SIF structure, and atomically replace the destination. A failed download or validation preserves the previous file. Allow space for both the old and new images, plus Apptainer's cache.
Each call pulls the selected public image using Apptainer's cache; registry login
is not required. The default image is always generic; the variant is not inferred
from the filename or existing image. Repeat --image when updating a nondefault
image. The default filename is thor-<image>_dev.sif in the current directory.
Use --digest sha256:<64-hex-digits> (Python: digest="sha256:...") to pull an
exact registry artifact of the selected variant. The default filename then becomes
thor-<image>_sha256-<64-hex-digits>.sif, keeping it separate from the development
image. Digests must use lowercase hexadecimal. For release tags, other variants,
or other registries, use apptainer pull directly.
Use runtime="/path/to/apptainer" or --runtime /path/to/apptainer for a custom
executable. SIF validation checks structure, not signatures or THOR compatibility.
Before replacing the image, the updater compares the installed thor-rt source
revision with the container's org.opencontainers.image.revision label. A mismatch
emits a RevisionWarning showing both revisions and continues the update. Missing
metadata or an unsuccessful metadata inspection also warns, reporting the unknown
revision. Standard Python warning filters apply.
Wheels and source distributions carry their build revision. Editable installs read the current HEAD of their own checkout, so switching branches changes the reported revision without reinstalling. You can inspect both revisions from Python:
from thor.container import python_revision, container_revision
print(python_revision())
print(container_revision(image))
This check covers the source revision of thor-rt. It does not compare uncommitted
edits or independently installed thor-live, thor-mcp, or thor-bindings.
Matching revisions do not establish compatibility of runtime settings or dependencies.
The updated image takes effect the next time it is opened. Restart a live session to use it. Apptainer itself and the Python packages are unchanged.
Container identity and reproducible runs
The org.opencontainers.image.revision label records the full THOR Git commit.
This is sufficient to identify the source revision. Rebuilding that commit can
still produce a different image: the toolchain base and apt packages can change.
Three identifiers serve different purposes:
| Identifier | Identifies |
|---|---|
thor_revision |
THOR source commit, also embedded in the SIF |
sif_sha256 |
Exact bytes of the local SIF file |
registry_reference ending in @sha256:… |
Registry manifest used to retrieve that image |
The SIF checksum and registry manifest digest are different hashes. Do not
substitute the SIF checksum into an oras://…@sha256:… reference.
The Release Containers workflow publishes a thor-<variant>-provenance-<attempt> artifact
containing thor-<variant>.provenance.json. The same JSON is included in each
build job's summary. It records the three identifiers above, the SIF size, and
the build-attempt URL. Reruns get separate artifacts. After upload, the workflow
fetches the raw registry manifest with ORAS, checks that its SIF checksum and
size match the local image, and hashes that manifest for the pinned reference.
A competing tag update to a different image fails verification; later updates
of :dev cannot change an already recorded reference. Publication manifests are
available for builds using this workflow; older builds may not have one.
For a production run, download that manifest from the chosen workflow run, then pull its pinned reference and verify the local image before starting THOR:
# Requires jq. Keep these files in the run directory with the configuration.
REFERENCE=$(jq -er '.registry_reference' thor-generic.provenance.json) &&
apptainer pull thor.sif "$REFERENCE" &&
thor-tools container provenance thor.sif \
--manifest thor-generic.provenance.json > container-provenance.json &&
apptainer run --env ACPP_VISIBILITY_MASK=omp thor.sif config.yaml
The provenance command hashes the entire SIF and compares its checksum, size,
and embedded THOR revision against the publication manifest. A mismatch exits
with an error and prints no JSON. Use && as above so a failed verification
prevents the simulation from starting. Treat the manifest as trusted publication
metadata; this comparison does not authenticate its author or verify signatures.
The equivalent Python inspection is:
import json
from pathlib import Path
from thor.container import image_provenance
record = image_provenance("thor.sif", manifest="thor-generic.provenance.json")
Path("container-provenance.json").write_text(json.dumps(record, indent=2) + "\n")
Without --manifest (or the Python manifest argument), inspection still records
the SIF checksum and any available source revision, but registry_reference is
null. A local SIF does not reliably reveal its original registry manifest.
Keep a copy of such an image to reproduce the run. Inspection is offline, reads
the whole file, and does not execute the container. It reports unavailable source
metadata as null unless a manifest was supplied, in which case verification fails.
The provenance command is explicit; invoking apptainer run or launching a
Python live session does not automatically create this record. Keep the record
with the run's configuration, random seeds, precision/backend settings, and
relevant host hardware and driver information. The same container does not
guarantee identical numerical results on different devices.
Publication and retention policy
:devis a moving development alias. A local copy updates only when pulled.- Preserve the embedded THOR commit and use registry digests to pin exact images; a separate permanent tag for every build is not required.
- Retain all published THOR image digests, including versions that become
untagged when
:devmoves. The repository has no registry cleanup workflow. Any future cleanup must preserve images referenced by simulations or releases; being untagged is not evidence that an image is unused. - Workflow provenance artifacts have a requested retention of 90 days, subject to repository limits. Archive the JSON with simulation outputs rather than relying on Actions artifact retention. Archive the SIF as well when long-term availability must be independent of the registry.
Setting Thread Count
Control the number of OpenMP threads with OMP_NUM_THREADS:
apptainer run --env OMP_NUM_THREADS=8 --env ACPP_VISIBILITY_MASK=omp \
thor-generic_dev.sif config.yaml
MPI (multiple ranks)
THOR parallelises MCRT across MPI ranks. The thor-generic image ships a full
Open MPI 4.1 stack (launchers included), so a single-node, multi-rank job
runs entirely inside one container — the container's own mpirun launches the
ranks:
apptainer exec --cleanenv --env OMP_NUM_THREADS=8 --env ACPP_VISIBILITY_MASK=omp \
thor-generic_dev.sif mpirun --map-by slot:PE=8 --bind-to core -np 3 /opt/thor/bin/thor config.yaml
- Rank↔core binding (
--map-by slot:PE=N --bind-to core) — this is the part that matters for hybrid MPI+OpenMP. By default Open MPI pins each rank to a single core, so theOMP_NUM_THREADSOpenMP threads stack onto that one core (measured ~2.6× slower).--map-by slot:PE=Ninstead gives each rank its own N physical cores (setN = OMP_NUM_THREADS), and AdaptiveCpp honours the resulting cpuset — so the threads spread with proper locality. Constraint:N × ranks ≤ physical cores. Simpler fallback:--bind-to noneleaves ranks unpinned so the threads spread across the whole node — correct thread count and it fixes the slowdown, but with no per-rank NUMA locality (fine on a single socket, suboptimal across sockets). Use it when the core count is unknown or PE-mapping is awkward. --cleanenvstops the host's OpenMPI MCA environment from leaking into the container, which can crashMPI_Initon GPU-less nodes.--env OMP_NUM_THREADS: bound the OpenMP threads per rank.--cleanenvwipes any hostOMP_NUM_THREADS, so without this each of the-np 3ranks defaults to all cores and oversubscribes the node. Set it to roughly cores-per-node ÷ ranks.- Cold JIT cache: with the SSCP/JIT
thor-genericimage, ranks racing on AdaptiveCpp's shared kernel cache can spamCould not remove kernel cache file. Give each rank its own cache withACPP_APPDB_DIR=…_rank_${OMPI_COMM_WORLD_RANK}(e.g. wrap the launch inbash -c) — seeregression_tests/CI/shellmodel/test_mpi_container.shfor the pattern. (The AOTthor-ompimage has no JIT cache and is unaffected.) - Set
device: cpu-openmpin your config to matchACPP_VISIBILITY_MASK=omp. - Multi-rank support is per dataset: uniform grid and point-cloud (gas) domain-decompose across ranks; analytical models (e.g. the shell model, as the container MPI regression exercises) replicate the domain and split photons across ranks; octree and PSS abort with more than one rank.
For multi-node runs the launcher must sit outside the container so it can
span nodes (srun --mpi=pmix apptainer exec … thor). That path needs the host
MPI to be PMIx-compatible with the container's Open MPI 4.1 — see
Running on HPC Clusters.
Regression test
regression_tests/CI/shellmodel/test_mpi_container.sh runs the shellmodel
MCRT regression as a 3-rank job in a container. Point it at an image with
THOR_SIF=/path/to/thor-generic.sif bash test_mpi_container.sh.
GPU usage
thor-generic carries both the CUDA and ROCm runtimes in one image; backend selection happens at exec time, not build time.
NVIDIA
Requirements:
- NVIDIA driver installed on the host
- The
--nvflag (binds host GPU drivers into the container) device: gpuin your config YAML
AMD
Requirements:
- ROCm-supported AMD GPU (Instinct MI-series, supported Radeon Pro / RX)
amdgpukernel module loaded (/dev/kfdand/dev/dri/renderD*must exist)- The
--rocmflag device: gpuin your config YAML
Fedora hosts: libnuma ABI conflict with --rocm
Apptainer's --rocm binds the host's libnuma.so.1. On Fedora ≥ 39 (glibc ≥ 2.40) this conflicts with the Ubuntu 24.04 glibc inside the container (GLIBC_ABI_GNU2_TLS not found) and every acpp backend fails to load. Workaround:
Unsupported AMD targets
ROCm ships device-libs bitcode only for officially supported gfxes. Consumer iGPUs (e.g. gfx1036 on Ryzen 9000 desktop) hit Code object construction failed at JIT time. Sometimes --env HSA_OVERRIDE_GFX_VERSION=10.3.0 (gfx1030) works as a hack; for production use, target a supported device.
Selecting FP32 or FP64
The GPU-capable images ship two binaries side-by-side — FP64 (thor-fp64, default) and FP32 (thor-fp32) — and a wrapper that picks one based on the THOR_PRECISION env var:
# FP64 (default)
apptainer run --nv --env ACPP_VISIBILITY_MASK=cuda thor-generic_dev.sif config.yaml
# FP32 — typically much faster on consumer GPUs (RTX, GTX, RDNA),
# which have crippled FP64 throughput
apptainer run --nv --env ACPP_VISIBILITY_MASK=cuda --env THOR_PRECISION=fp32 \
thor-generic_dev.sif config.yaml
FP32 is reduced-precision
Float32 is sufficient for many MCRT runs but can degrade results in long photon chains, high optical depths, or tight numerical tolerances. Validate against an FP64 reference for your problem before relying on FP32 production runs.
Available Images
| Image | Description | Precision | Access |
|---|---|---|---|
thor-generic |
Portable SYCL build. CUDA + ROCm runtimes bundled; backend chosen at exec time. | FP64 + FP32 | public |
thor-omp |
CPU-only AOT build (AdaptiveCpp OMP backend). Smaller; best raw CPU perf. | FP64 | public |
thor-env-only |
Build environment (no thor binary) — for compiling THOR yourself. |
— | private |
Building Custom Containers
For Development Only
Most users should use the pre-built thor-generic image above.
pip install typer pyyaml jinja2
cd apptainer
# List configurations
./build.py list
# Build container
./build.py build omp
# Build from local source (requires initialized submodules)
./build.py build omp --local
Cluster Configurations
Configurations in apptainer/clusters/:
| Configuration | Description |
|---|---|
generic.yaml |
Portable SSCP build with CUDA + ROCm runtimes; FP64 + FP32 binaries |
omp.yaml |
OMP AOT backend, FP64, smaller image |
env-only.yaml |
Build environment only (no THOR binary) |
example-hpc.yaml |
Template for custom cluster-specific configs |
Create a custom config:
cp apptainer/clusters/example-hpc.yaml apptainer/clusters/my-cluster.yaml
# Edit march for your CPU (e.g., znver3 for AMD EPYC Milan)
./build.py build my-cluster
Common march values: x86-64-v3 (generic), znver2/znver3/znver4 (AMD EPYC), skylake-avx512/icelake-server (Intel Xeon).
Shipping FP64 + FP32 binaries
Set dual_precision: true in your cluster YAML to build both binaries into the
same image with a THOR_PRECISION runtime dispatch wrapper (see
Selecting FP32 or FP64 above). The second cmake
build runs in build-fp32/ with -DTHOR_USE_FP32=ON; on-disk overhead is
small (~10 MB compressed) because the two binaries share most of their code.
Useful for any GPU-targeted config; off by default for CPU configs since FP32
yields little benefit on the CPU path.
Environment Container
The published thor-env-only package is private. Downloading it requires access
and apptainer registry login --username <github-user> docker://ghcr.io with a
GitHub personal access token (classic) scoped to read:packages.
For development without rebuilding containers:
./build.py build env-only
# Build and run THOR from source
apptainer exec build/thor-env-only.sif bash -c \
"cd /path/to/thor && cmake -B build && cmake --build build -j\$(nproc)"
apptainer exec build/thor-env-only.sif /path/to/thor/build/src/thor config.yaml
Accessing Host Data
By default, Apptainer only mounts your home directory and a few system paths inside the container. If your simulation data or Cloudy tables live elsewhere (e.g. /virgotng/, /scratch/, /data/), you must bind-mount those paths:
# Bind a single path
apptainer run --bind /virgotng thor-generic_dev.sif config.yaml
# Bind multiple paths
apptainer run --bind /virgotng,/scratch thor-generic_dev.sif config.yaml
Alternatively, set the APPTAINER_BIND environment variable (useful in job scripts):
For full details, see the Apptainer Bind Paths documentation.
Troubleshooting
fuse2fs not found / gocryptfs not found warnings: These informational messages from Apptainer can be safely ignored. THOR containers use Docker-based images and do not require EXT3 filesystem mounting or encrypted overlays.
Slow performance: Check march matches your CPU. Set OMP_NUM_THREADS appropriately.