Containers and multi-architecture deployment
RIFT runs its compute jobs (ILE, CIP) inside a Singularity/Apptainer container
on HTCondor pools such as the OSG. Historically the environment variable
SINGULARITY_RIFT_IMAGE names a single image, and every job is pinned to
it:
export SINGULARITY_RIFT_IMAGE=/cvmfs/singularity.opensciencegrid.org/.../rift:production
That still works exactly as before. This page documents two additions:
a container *family* — point
SINGULARITY_RIFT_IMAGEat a YAML manifest describing several images that target different GPU compute capabilities, and let HTCondor pick the right one per matched machine; anda multi-target build that produces such a family from one template.
For an operator workflow that inventories a target GPU pool and creates container-cache warmup jobs, see survey_scan.
Note
If SINGULARITY_RIFT_IMAGE is a plain .sif path or a single
osdf:// URL, behavior is unchanged — the manifest machinery is never
engaged. A manifest is recognized purely by its .yaml / .yml suffix.
Deploying a container family
Set SINGULARITY_RIFT_IMAGE to a manifest file instead of a single image:
export SINGULARITY_RIFT_IMAGE=`pwd`/rift_container_family.yaml
Everything else — util_RIFT_pseudo_pipe.py, --use-singularity,
--use-osg — is identical. When the pipeline builds the ILE/CIP submit
files it reads the manifest and emits an expression-valued container
selection (see What the pipeline generates below).
Manifest format
version: 1
# Machine ClassAd attribute the selection expression tests.
# Default GPUs_Capability (see "GPU attribute names" below).
capability_attr: GPUs_Capability
# Catch-all image (innermost else of the selection); MUST be CPU-safe,
# since it is also used when no GPU capability is advertised.
fallback: default
containers:
# Broadly-compatible image for older machines. On CVMFS: referenced in
# place and lazy-fetched (only the selected image is ever pulled), never
# transferred.
- label: default
image: /cvmfs/singularity.opensciencegrid.org/oshaughn/rift_container_default.sif
cuda_capability_min: 3.5 # inclusive lower bound for this image
cuda_capability_max: 8.0 # informational; null = open-ended
note: "cupy-cuda11x, ubuntu22.04/cuda11.8"
# Newer image for higher-capability GPUs. Delivered via osdf: only the
# matched machine fetches it (selective transfer).
- label: modern
image: osdf:///igwn/staging/oshaughn/rift_containers/rift_container_modern.sif
cuda_capability_min: 8.0
cuda_capability_max: null
note: "cupy-cuda12x, ubuntu22.04/cuda12.4"
Field |
Meaning |
|---|---|
|
Manifest schema version (currently |
|
Machine ClassAd attribute the selection expression tests (default
|
|
|
|
Human id; also referenced by |
|
A CVMFS/local path (referenced in place, lazy-fetched) or an
|
|
Inclusive lower capability bound for this image. |
|
Informational upper bound ( |
|
Free text. |
A starting manifest lives at containers/rift_container_family.yaml in the
source tree.
Warning
Keep the family consistent. A single SINGULARITY_BASE_EXE_DIR is
applied to every image in the family — the ILE/CIP jobs locate the
executable as SINGULARITY_BASE_EXE_DIR + <exe name>, with no per-image
override. Every image in a manifest must install RIFT’s executables at the
same in-container path (and share a common layout / Python / entrypoints).
Build them from the same rift_container.def.in template
(build_family.sh does this); do not hand-mix images with different
internal layouts. The same applies to SINGULARITY_BASE_EXE_DIR_HYPERPIPE
if you use hyperpipe.
What the pipeline generates
For a manifest, the ILE (and CIP) Condor submit files get:
``MY.SingularityImage`` — an unquoted
ifThenElseexpression that selects the highest-capability image the matched machine can run, with thefallbackimage as the innermostelse(used when the machine’s capability is below every threshold):ifThenElse(TARGET.GPUs_Capability >= 8.0, "./rift_container_modern.sif", "/cvmfs/.../rift_container_default.sif")
Selective transfer — only
osdf://images are fetched, and only on the machine that selected them, via a single HTCondor$$()match-time token appended totransfer_input_files. CVMFS/local images are referenced in place and never transferred, so the whole family is never pulled:$$([ (TARGET.GPUs_Capability >= 8.0 ? "osdf:///.../rift_container_modern.sif" : "") ])
request_diskis not auto-sized — set it to your largest single transferred image.``require_gpus`` floor —
Capability >= <lowest min across the family>, combined (&&) with anyRIFT_REQUIRE_GPUSyou set. Both apply; neither is dropped. This stops jobs matching a GPU that no image in the family supports.A capability-defined ``Requirements`` clause —
TARGET.GPUs_Capability =!= undefined. The selection above is deliberately not undefined-guarded: a slot that does not advertise the machine-level capability rollup could be anything (including a Blackwell that hard-fails on the older fallback image), so the safe action is to not match it rather than guess. Measured on the CIT pool, a large fraction of GPU slots satisfy the per-GPUrequire_gpusfloor yet do not advertise the rollup attribute; without this clause those jobs go on hold with “Cannot expand $$ expression”.
CPU-only jobs are handled differently. CIP requests no GPU, so its matched
slot advertises no capability at all and a capability-keyed selection cannot
resolve — it would hold the job. CIP therefore collapses to a single fixed
container: the manifest fallback image (hence the requirement that the
fallback be CPU-safe), quoted as a plain literal, with no $$() token and no
capability Requirements clause.
Choosing a delivery mode (legacy vs container universe)
The expression-valued MY.SingularityImage above is evaluated on the
execute side. That works on a local HTCondor pool, but OSPool glidein
pilots read SingularityImage as a literal string, so an ifThenElse
lands verbatim and the job holds. Two opt-in modes solve this; pick one with an
environment variable at DAG-build time.
Mode |
Behaviour |
|---|---|
(default) |
Legacy: |
|
Recommended for OSG. |
|
Older fallback, ILE only. Condor runs a generated
|
Under asimov, set the variable from the blueprint rather than the shell:
scheduler:
singularity image: /path/to/rift_container_family.yaml
singularity base exe directory: /usr/local/bin/
environment variables:
RIFT_CONTAINER_UNIVERSE: 1
Note
With a family manifest whose images are osdf:// URLs, the pipeline also
enables the matching transfer credential automatically
(use_oauth_services = scitokens, or igwn for igwn+osdf:). The
single-image code path keys that off the SINGULARITY_RIFT_IMAGE string
itself, which for a manifest is just a .yaml path — so the manifest’s
image URLs are inspected instead.
Warning
Why the container-universe selector names basenames, not URLs.
condor_submit parses container_image before any $$ expansion and
derives the job ad’s ContainerImage — the name the image will have in the
job scratch dir — as the text after the last /. A selector containing
full paths is therefore cut in half, and the fragment that survives is not a
valid image name. Submitting that form to the IGWN pool holds the job at the
execute point:
PREPARE_JOB (prepare-hook) failed (reported status 001):
Unable to download or build singularity image cutest_busybox_...sif") ])
So the selector emits basenames only (no /), the whole $$ token
survives into ContainerImage, and the schedd expands it at match time
(MATCH_EXP_ContainerImage = "rift_container_modern.sif"). The image itself
is delivered by the comma-free $$() transfer token, and MY.TransferInput
is pinned so condor_submit does not append the basename selector to
TransferInput as a bogus extra input file. Verified end to end on an OSPool
glidein against $CondorVersion: 25.11.1.
Consequence: every image in a family used with container universe must be a
transferable URL. An image referenced in place (a CVMFS or local path) can
only be named by its full path, which would reintroduce the truncation, so
build_container_image_select() raises
ContainerManifestError for such a family. Stage those images at a URL, or
use RIFT_CONTAINER_RUNTIME_SELECT=1.
Per-host ILE executable and memory
A family entry may also choose the ILE executable and the memory request. For example, to run the JAX ILE on GPUs with at least 16 GB and the standard ILE elsewhere:
fallback: legacy
containers:
- label: legacy
image: osdf:///igwn/.../rift_cuda12.sif
cuda_capability_min: 6.0
- label: jax_big
image: osdf:///igwn/.../rift_cuda12.sif # entries may share an image
cuda_capability_min: 6.0
select_requirements: TARGET.GPUs_GlobalMemoryMb >= 16000
ile_exe: integrate_likelihood_extrinsic_jax
request_memory: 16000 # MB
select_requirements is ANDed into the entry’s capability test as
(expr) =?= true, so a slot that does not advertise an attribute it reads
falls through to the next entry. At equal cuda_capability_min an entry with
select_requirements is tested first. It needs cuda_capability_min, may
not contain , or /, and the fallback may not set it. Entries without
ile_exe or request_memory use the job defaults.
A GPU ILE job then carries one ClassAd selector per choice, all over the same
branches: the image, request_memory, MY.RIFTILEExe, and
MY.RIFTILEProfile (the label). The executable is passed as a trailing
--rift-ile-exe=$$([MY.RIFTILEExe]) argument, which ile_pre.sh (or a small
ile_dispatch.sh when there is no frame prescript) removes before running it. HTCondor carves the slot at the selected memory,
and keeps the expressions so a job rematched after eviction selects again. To see
what each job got:
condor_history <cluster> -af MATCH_EXP_RIFTILEProfileMatched MemoryProvisioned
Limits:
Opt-in per job (
ile_profiles=True). Only theILE,ILE_puffandILE_fetchjobs ofcreate_event_parameter_pipeline_BasicIterationuse it.ILE_extrkeeps the default executable, because its output converter depends on which executable ran.GPU jobs and HTCondor backends only.
Not available with
RIFT_CONTAINER_RUNTIME_SELECT, which picks the image after the slot is carved and cannot evaluateselect_requirements.The build-time checks
util_RIFT_pseudo_pipe.pyapplies to--use-jax-ile(calibration marginalization, OSG) do not see a manifestile_exe. Check that the run’s ILE options are supported by every executable the family names.Select on attributes that are the same for the partitionable and the dynamic slot (GPU capability, GPU memory,
TotalCpus), not onMemory.condor_qedit <job> RequestMemory <N>replaces the expression with a constant.
GPU attribute names
Two different ClassAd namespaces are involved, and they are kept separate:
The image selection
ifThenElsereads the machine ad. The default attribute isGPUs_Capability. Override it per run with the environment variableRIFT_GPU_CAPABILITY_ATTR, or per manifest withcapability_attr. Verify what your pool advertises:condor_status -constraint 'TotalGPUs > 0' -autoformat GPUs_DeviceName GPUs_Capability GPUs_GlobalMemoryMb
Not every GPU host advertises this; on such hosts the expression collapses to the fallback image and the
require_gpusfloor does the steering.The ``require_gpus`` floor uses the require_gpus sub-ad attribute
Capability(unprefixed — notTARGET., notGPUs_).
Note
These mechanisms have been validated on a real HTCondor pool + GPU: the
attribute names, the require_gpus floor (matching a compatible GPU and
excluding an incompatible one), the $$() match-time image selection, and
tolerance of the empty-result case for a manifest that mixes CVMFS and
osdf entries. The remaining item for a first real OSG run is that the
pilot evaluates the expression-valued MY.SingularityImage and honors a
relative ./name.sif produced by it.
Building a container family
The build lives under containers/:
rift_container.def.in— an Apptainer definition template with@@BASE_IMAGE@@/@@CUPY_PKG@@placeholders.build_family.sh— renders one.defper build-matrix entry and runsapptainer build. The first matrix entry keeps the current production base image, so the family always includes a broadly-compatible image for older machines.requirements-container.txt— the shared, unpinned pip dependency set (the cupy wheel is the only per-entry difference).
# render the per-entry .def files only (no apptainer needed)
containers/build_family.sh --render-only ./container_family
# render and build each .sif (requires apptainer)
containers/build_family.sh ./container_family
# on shared clusters (e.g. CIT), build with --fakeroot to avoid the
# unprivileged proot engine (whose mksquashfs step fails):
containers/build_family.sh --fakeroot ./container_family
Each run also writes a rift_container_family.generated.yaml stub: fill in
each image: with where you published the .sif (a CVMFS path or
osdf:// URL) and you have a deployable manifest.
Note
On clusters without setuid apptainer or unprivileged user namespaces, a plain
build falls back to the proot engine and fails at mksquashfs
(ptrace(TRACEME): Operation not permitted). Use --fakeroot (needs
/etc/subuid + /etc/subgid entries), or --sandbox to build a
directory that skips mksquashfs and convert it to a .sif later on a
capable host. See the build-troubleshooting section of
containers/README.md.
The top-level rift_container.def is unchanged and remains the default
single-image build.
Catching dependency breakage early (CI canary)
The container ships an unpinned dependency set and clones RIFT at build time,
so a fresh upstream release (for example swig>=4.4.0) can silently break
RIFT and only surface when a container rebuild fails. Two non-blocking CI
jobs guard against this — container-dep-canary (installs the same unpinned
containers/requirements-container.txt set and runs the import check) and
container-swig-canary (exercises the pixi swig-post44 lane). They
run on every push/PR and on a weekly schedule, so a breaking upstream release is
flagged even with no RIFT commit.