Manual Deployment (systemd)#
Deploy Spur by hand across physical or virtual machines: install the binaries, write a config file, and run the daemons as systemd services. This page is the no-Ansible path.
Note
For production clusters, use the Ansible toolkit instead — see Deploying with Ansible (recommended). It automates everything below, including systemd units, symlinks, and PostgreSQL accounting. Follow this page to understand the internals or to stand up a small, ad-hoc cluster.
Get the Binaries#
Install the latest stable release with the one-line installer. By default it installs
to ~/.local/bin (no sudo required):
curl -fsSL https://raw.githubusercontent.com/ROCm/spur/main/install.sh | bash
export PATH="$HOME/.local/bin:$PATH"
This installs spur, spurctld, spurd, the per-job supervisor spurstepd,
and the credential mint spurauthd (needed only for
native credentials), and makes the CLI reachable under its
Slurm-compatible names (sbatch, squeue, sinfo, …).
For --mpi=pmix, use a nightly tarball (includes spur_mpi_pmix.so);
see Install Spur with the MPI plugin.
To build from source instead, install the Rust toolchain and protobuf-compiler, then
build the binaries:
git clone https://github.com/ROCm/spur.git && cd spur
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source "$HOME/.cargo/env"
sudo apt install -y protobuf-compiler build-essential
cargo build --release -p spur-cli -p spurctld -p spurd -p spur-stepd -p spurauthd
The binaries land in target/release/. For a fuller build walkthrough see
Building.
Note
Ports used across hosts: 6817 (controller gRPC API and accounting), 6818 (agent gRPC), and 6821 (Raft, controller-to-controller). Open these between the relevant hosts.
Daemon Flags#
The two daemons are configured with command-line flags. The most common are below.
spurctld#
Flag |
Default |
Meaning |
|---|---|---|
|
|
Config file. If it does not exist, built-in defaults are used. |
|
(from config) |
gRPC listen address; overrides the config file. |
|
|
Raft and scheduler state directory. |
|
|
Log verbosity. |
|
off |
Run in the foreground instead of daemonizing. |
spurd#
Flag |
Default |
Meaning |
|---|---|---|
|
|
Config file for local agent settings (see the note below). |
|
|
Controller endpoint(s). Accepts a comma-separated list for HA failover. |
|
(system hostname) |
Node name as it appears in |
|
(auto-detected) |
Advertised IP the controller uses to reach this agent. |
|
|
Agent gRPC listen address. |
|
( |
Directory for this agent’s own persisted runtime state (job supervisor
sessions that survive an |
|
|
Log verbosity. |
Note
Work runs under a supervisor — one per job, plus one per numbered srun
step — so batch, container, allocation and MPI jobs and the steps inside
them keep running across an spurd restart or upgrade. A step’s exit
status is reported over the reconnect, so a job whose agent restarted
mid-step still completes with the right exit code.
Two launches are still unsupervised and do not survive a restart: a step
given its own --container-image, and an srun --pty that allocates its
own job. A terminal opened inside an existing allocation is supervised —
see Interactive & Parallel Jobs. A container step killed that way also
leaks its unpacked rootfs, which nothing reclaims. Containerized jobs are
supervised and survive a restart normally.
--mpi=pmix work survives a restart the same way: a job and an inner
srun --mpi=pmix step each host their PMIx server inside their own
supervisor. A step given its own --container-image is the exception —
its server stays in spurd, so restarting the agent breaks its
rendezvous along with the step itself.
Before the controller will fence a supervisor belonging to a superseded
run, the reporting node has to prove its identity, and that takes two
settings together: [auth] jwt_key (or jwt_key_file) and
[admission] mode = "token". The credential a recovery report is checked
against is only minted when an agent registers with an admission token, so
a signing key on its own — the common case, since admission defaults to
open — proves nothing.
With either setting missing, recovery still works and is simply taken on trust: the agent supervises and re-adopts as normal, and the controller keeps the job alive rather than dropping a supervisor it cannot verify. What it will not do is fence one, so a supervisor left over from a superseded run is not torn down by this path. See Configuration Reference (spur.conf) for both settings, and note that without a signing key no node credential is issued or demanded at all, so node identity is unattested cluster-wide.
Note
Node identity and networking (controller address, hostname, listen address) come
from CLI flags. spurd also reads spur.conf for local agent settings —
[hooks], [devices] (GRES and CDI), rlimits.memlock, [cgroup],
[cluster], and [mpi]. If the file is absent, the agent logs a warning and
falls back to defaults for those sections, which is fine when none of them are
in use. [devices] inventory is also re-discovered periodically after
startup; see Configuration Reference (spur.conf).
Quick Start: Two-Node Cluster#
The fastest way to get a two-node cluster running — one controller node and one compute node, no WireGuard, plain LAN.
On the controller node (e.g. 10.0.0.1):
# /etc/spur/spur.conf
cluster_name = "my-cluster"
[scheduler]
plugin = "backfill"
interval_secs = 1
[[partitions]]
name = "default"
default = true
nodes = "ALL"
max_time = "24:00:00"
sudo mkdir -p /var/spool/spur
spurctld -D -f /etc/spur/spur.conf # starts in the foreground; Ctrl-C to stop
# use systemd for production (see below)
On the compute node (e.g. 10.0.0.2), the same spur.conf is fine or
omit it entirely — identity and networking come from flags:
spurd -D \
--controller http://10.0.0.1:6817 \
--hostname compute-1 \
--address 10.0.0.2
Verify both are up:
sinfo # shows compute-1 in idle state
srun hostname # runs on the compute node and prints its name
Note
Ports to open between the two machines: 6817 (controller API) and 6818 (agent). If you add more controllers for HA, also open 6821 (Raft).
Note
By default spurd refuses to run jobs submitted as UID 0 (root). This is a
safety guard for shared clusters. On a private dev cluster where all submitters
are trusted, enable it in spur.conf:
[auth]
allow_root_jobs = true
Setting Up the Controller#
Initialize the network for encrypted node-to-node communication (skip this for a direct LAN deployment):
sudo spur net init --cidr 10.44.0.0/16 --port 51820
This sets up a WireGuard mesh, prints the server public key, and outputs a join command template for workers.
Note
The steps below cover a single-controller mesh by hand. For the full mesh reference —
why net add-peer --endpoint is required for worker↔worker connectivity, node
removal with net remove-peer, HA over the mesh, and k0s inside the mesh — see
WireGuard mesh clusters.
Create /etc/spur/spur.conf. The repository includes examples/spur.conf with the
full annotated set of fields. A minimal example:
cluster_name = "gpu-cluster"
[controller]
listen_addr = "[::]:6817"
hosts = ["10.44.0.1"]
state_dir = "/var/spool/spur"
[scheduler]
plugin = "backfill"
interval_secs = 1
[network]
wg_enabled = true
wg_interface = "spur0"
agent_port = 6818
# reject_loopback_comm_addr = true # optional: refuse agent registrations whose comm address is loopback or link-local
[[partitions]]
name = "gpu"
default = true
nodes = "gpu-node-[1-2]"
max_time = "72:00:00"
[[nodes]]
names = "gpu-node-[1-2]"
cpus = 128
memory_mb = 512000
gres = ["gpu:mi300x:8"]
# address = "10.44.0.2" # optional default comm address before the agent registers
Start the controller in the foreground to check it comes up:
sudo mkdir -p /var/spool/spur
spurctld -D -f /etc/spur/spur.conf
For production, run it as a systemd service. Copy the binary to /usr/local/bin and
use /var/spool/spur for state (the daemon default):
# /etc/systemd/system/spurctld.service
[Unit]
Description=Spur Controller Daemon (spurctld)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/spurctld -f /etc/spur/spur.conf --state-dir /var/spool/spur --log-level info
Restart=on-failure
RestartSec=3
User=root
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
Enable and start it:
systemctl daemon-reload
systemctl enable --now spurctld
Note
The one-line installer places binaries in ~/.local/bin by default. If you install
that way, adjust ExecStart to match — this unit assumes /usr/local/bin.
High Availability#
For HA, run spurctld on 3 (or 5) nodes with Raft consensus. Add all controller
addresses, in the same order on every controller, to the peers list in the config
(Raft uses port 6821):
[controller]
peers = [
"10.44.0.1:6821",
"10.44.0.2:6821",
"10.44.0.3:6821",
]
Raft automatically elects a leader. Workers connect to any controller and are redirected to the current leader.
Joining Worker Nodes#
On each worker, join the WireGuard mesh (skip for a direct LAN deployment):
sudo spur net join \
--endpoint 192.168.1.100:51820 \
--server-key <controller-pubkey> \
--address 10.44.0.2
Then register the worker on the controller:
sudo spur net add-peer \
--key <node-pubkey> \
--allowed-ip 10.44.0.2/32 \
--endpoint 192.168.1.101:51820
Start the agent:
spurd -D \
--controller http://10.44.0.1:6817 \
--hostname gpu-node-1 \
--address 10.44.0.2 \
--listen [::]:6818
--address sets the advertised comm address. Alternatively, set the
SPUR_NODE_ADDRESS environment variable. Pass a routable IP or FQDN,
not the short hostname alone when /etc/hosts maps it to loopback.
The agent auto-detects CPUs, memory, and GPUs, then registers with the controller over the mesh.
For an HA quorum, pass every controller as a comma-separated list so the agent and CLI
fail over to a surviving node if one is unreachable. The same format works for the
SPUR_CONTROLLER_ADDR environment variable:
--controller http://10.44.0.1:6817,http://10.44.0.2:6817,http://10.44.0.3:6817
Repeat for each worker, incrementing the WireGuard address.
For production, run the agent as a systemd service:
# /etc/systemd/system/spurd.service
[Unit]
Description=Spur Node Agent (spurd)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/spurd --controller http://10.44.0.1:6817 --hostname gpu-node-1 --address 10.44.0.2 --listen 0.0.0.0:6818 --log-level info
Restart=on-failure
RestartSec=3
KillMode=process
User=root
LimitMEMLOCK=infinity
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
Important
KillMode=process is required for jobs to survive an agent restart.
systemd’s default, control-group, signals every process in the unit’s
cgroup on stop or restart. Job supervisors are deliberately detached from
the agent — their own session, reparented to init — so that
systemctl restart spurd leaves running work untouched. They nonetheless
remain in the unit’s cgroup, so the default kill mode terminates them and
the next agent startup reclaims the now-orphaned job.
Verify:
spur net status # WireGuard peers and handshake times (mesh only)
spur nodes # All registered nodes
Resource Limits (rlimits)#
By default, spurd raises RLIMIT_MEMLOCK to unlimited for every job step
before dropping to the submitting user. This is required for InfiniBand/RDMA
verbs (ibv_reg_mr, ibv_create_cq) and NCCL collective communication.
Without it, jobs fail with Cannot allocate memory from libibverbs.
The default can be changed in spur.conf:
[rlimits]
memlock = "unlimited" # default: RDMA/NCCL just works
# memlock = "inherit" # keep whatever spurd inherited
# memlock = "1073741824" # fixed cap in bytes
Note
With the default "unlimited" setting, a root spurd raises memlock
itself while still privileged, so LimitMEMLOCK on the unit is optional.
An unprivileged agent cannot raise the hard limit. Set
LimitMEMLOCK=infinity on the systemd unit in that case: systemd applies
it before dropping to User=. See MPI (PMIx) below.
Note
spurd also raises its own RLIMIT_MEMLOCK at startup, because kernels
before 5.11 charge the memory for a job’s BPF device filter against it rather
than against the memory cgroup.
This raise is unconditional: it happens before the configuration is read, so
a node with constrain_devices = false, or with [cgroup] off entirely,
still gets it. That is deliberate — the agent’s limit is then the same value
whether or not a BPF load ever happens — but it does mean that deferring the
device filter does not defer the change described next.
This changes what memlock = "inherit" yields. "inherit" means “do not
call setrlimit, keep whatever spurd has”, so an inherit job now
inherits the raised limit instead of the one the systemd unit granted. The
default "unlimited" sets the job’s limit outright and is unaffected, so only
a site that explicitly chose "inherit" sees a difference.
A site that would rather own the value can set LimitMEMLOCK=infinity on the
spurd unit and get the same effective limit from systemd. That is also the
fallback when spurd cannot raise it itself: lifting the hard limit needs
CAP_SYS_RESOURCE, and without it the agent can only raise the soft limit to
meet the existing hard one and logs that it could not go further.
CPU, Memory, and Device Limits (cgroups)#
spurd puts the processes it starts for a job into a cgroup-v2 group at
/sys/fs/cgroup/spur/job_<id>_<attempt> and enforces the per-node budget the
controller allocated — the cores and memory the scheduler actually granted this
node, not what the job asked for. The attempt suffix keys the cgroup by run
attempt rather than job ID alone, so a job launched again after a failure never
lands in a still-occupied cgroup left by a prior attempt that has not been
reaped yet.
This covers every process the agent starts for a job: sbatch scripts,
--pty jobs, containerized jobs (a container’s process tree inherits the job
cgroup), srun steps, spur exec, and interactive attach. An interactive
allocation (salloc, standalone srun) launches no payload of its own, so
its cgroup is created when the allocation is registered — the first step to
arrive then has one to join. What is left outside it is narrow, and is described
under What is not contained yet below.
Out of the box the job’s cgroup gets:
cpuset.cpuspinned to its allocated cores.memory.maxat its allocated memory, withmemory.highat the same value so the kernel reclaims against the job before killing it.memory.swap.maxleft alone: swap is unconstrained by default, as in Slurm, somemory.maxbounds resident memory only. Setconstrain_swapto make--mema hard cap.memory.oom.groupset, so an OOM kills the whole job rather than one process.pids.maxas a fork-bomb guard.A default-deny BPF device filter attached to the cgroup, so the job can open its allocated device nodes, the base pseudo-devices, and the shared host-infrastructure nodes (RDMA verbs, MIG capability nodes) listed under What the device filter allows without an allocation — and nothing else. Name any device the list misses in
extra_device_paths, or turn the filter off withconstrain_devices.
A job submitted without --mem has no memory budget, so the memory ceilings are
left unset.
Inspect what a running job actually got:
ls /sys/fs/cgroup/spur/ # one dir per running job attempt
cat /sys/fs/cgroup/spur/job_1234_1/cpuset.cpus
cat /sys/fs/cgroup/spur/job_1234_1/memory.max
cat /sys/fs/cgroup/spur/job_1234_1/memory.swap.max
bpftool cgroup show /sys/fs/cgroup/spur/job_1234_1 # the device filter, if attached
ls /sys/fs/cgroup/spur/job_1234_1/ # one step_<n> leaf per step
cat /sys/fs/cgroup/spur/job_1234_1/step_0/cpu.stat # that step's own CPU usage
The job directory normally holds the limits and no processes — those live in the
leaves; a step whose leaf could not be created falls back into the job directory
itself. srun steps are numbered from step_0, while a job’s own lifetime
runs in Slurm-named reserved leaves — step_batch for the batch payload and
step_extern for an allocation’s holder.
Enforcement requires spurd to run as root. An unprivileged agent logs a warning
and runs jobs unconstrained. Every knob — including turning enforcement off
entirely — lives in the [cgroup] section; see
Configuration Reference (spur.conf) for the full field list and the mapping from
Slurm’s cgroup.conf.
What is not contained yet#
Every process the agent starts for a job is confined beneath job_<id>_<attempt>
— the batch payload, srun steps, spur exec, and interactive attach alike
— so all of them are bounded by the job’s limits and checked against its device
filter. Each step runs in its own step_<n> leaf under that directory: cgroup
v2 refuses to hold processes in a node whose children have controllers enabled,
so the job node carries the limits and the steps sit beneath it. What remains is
a granularity gap inside the job rather than a hole between jobs:
What is missing |
Where it stands |
|---|---|
Per-step limits |
Each step gets its own |
Precise kill-by-step |
Cancelling one step signals its process tree rather than its cgroup, so a
step process that leaves that tree ( |
|
A supervised step runs its task hooks as the job user inside its own
|
Node-level prolog and epilog run uncontained, and that is by design:
they run before the job’s cgroup exists and after it is gone, and their purpose is
node-wide setup and teardown.
Note
A step now counts against the job’s budget. An srun step used to run
outside job_<id>_<attempt>, with no memory ceiling and no CPU pinning of its own; it
now shares the job’s memory.max, memory.high, and cpuset.cpus. A
site whose steps routinely overrun what the job asked for will start seeing
OOM kills where the same workload previously ran. Size --mem for the whole
job — steps included — or raise allowed_ram_percent to open a headroom
band above the allocation.
Practically: a user cannot exceed their job’s memory or core budget, or reach a
GPU the job was not allocated, from any process the agent starts for them —
batch script, step, or interactive shell. That holds only where enforcement
actually applied, though: an unprivileged agent, or one that could not create the
cgroup, runs the job unenforced with a warning. Set [cgroup] required = true
to refuse the work instead, which is what makes these limits a boundary you can
rely on between users on a shared node.
MPI (PMIx)#
Spur supports Open MPI jobs via --mpi=pmix on single-node and multi-node
allocations. The controller and CLI do not link libpmix; each compute node loads
spur_mpi_pmix.so from [mpi].plugin_dir when a PMIx job starts.
The PMIx server runs inside the supervisor (spurstepd) that owns the ranks —
one per (job, step), so a job and each of its srun --mpi=pmix steps get
their own — rather than inside spurd. It lives and dies with the ranks it
serves, not with the node agent. The supervisor inherits the agent’s
environment, so [Service] settings such as Environment=PMIX_MCA_gds=hash
still reach the server. The plugin is loaded by the supervisor and must be
readable at [mpi].plugin_dir on every agent.
Install Spur with the MPI plugin#
Use published tarballs (GitHub nightly releases or your internal artifactory
mirror of the same artifact). Do not copy cargo build artifacts from a
developer laptop unless you have verified glibc compatibility (see
Building).
Nightly and stable release tarballs include lib/spur/spur_mpi_pmix.so
(BUILD_MPI_PLUGIN=1 in the release pipeline). After install, confirm the
plugin is present:
ls "${INSTALL_ROOT}/lib/spur/spur_mpi_pmix.so"
where INSTALL_ROOT is the directory that contains bin/ (see layout
below).
On the controller and every compute agent:
# Example: install under ~/spur (binaries in ~/spur/bin)
mkdir -p ~/spur/bin ~/spur/etc
curl -fsSL https://raw.githubusercontent.com/ROCm/spur/main/install.sh \
| INSTALL_DIR="$HOME/spur/bin" bash -s -- nightly
# Or pin a specific nightly tag from GitHub / artifactory:
# ... bash -s -- nightly-YYYYMMDD-<sha>
export PATH="$HOME/spur/bin:$PATH"
spur --version
ls "$HOME/spur/lib/spur/spur_mpi_pmix.so"
install.sh layout (when INSTALL_DIR=$HOME/spur/bin):
Path |
Contents |
|---|---|
|
|
|
|
For a system-wide install (INSTALL_DIR=/opt/spur/bin), the plugin lands in
/opt/spur/lib/spur/.
Agent OS prerequisites (not bundled in the Spur tarball):
OpenPMIx runtime —
libpmix.soon the agent (Spur’s plugin links against it at load time). Version must satisfy[mpi].pmix_min_version. Confirm withldd spur_mpi_pmix.so | grep pmix. Sites that ship HPC-X / vendor Open MPI often have a secondlibpmix.so.2under/usr/mpi/...(PMIx 3.x). The plugin must load the same OpenPMIx the.sowas linked against (typically distro OpenPMIx 5, e.g. Debian/usr/lib/x86_64-linux-gnu/pmix2/lib/libpmix.so.2). Do not pointLD_LIBRARY_PATHat the vendor PMIx for the plugin or the ranks.Open MPI — libraries matching how application binaries were built (
mpicc,LD_LIBRARY_PATH,OPAL_PREFIX).
Add [mpi] to spur.conf on all hosts (controller and agents), with
plugin_dir matching where the ``.so`` actually is. The code default is
/usr/lib/spur; install.sh with INSTALL_DIR=/opt/spur/bin places the
plugin in /opt/spur/lib/spur. Copying the plugin to one path while leaving
plugin_dir at the other means --mpi=pmix never loads it. Use an
absolute path — TOML does not expand $HOME:
[mpi]
plugin_dir = "/usr/lib/spur" # or /opt/spur/lib/spur — must match the installed .so
pmix_tmpdir = "/tmp/spur-pmix"
pmix_min_version = "4.1.0"
[rlimits]
memlock = "unlimited"
Create the PMIx scratch dir on every agent: sudo mkdir -p /tmp/spur-pmix && sudo chmod 1777 /tmp/spur-pmix.
``spurd`` for MPI. Prefer a root agent. [rlimits] memlock only raises
the limit while spurd is still privileged. For an unprivileged agent
(a systemd User= other than root), set LimitMEMLOCK=infinity on the
unit so systemd raises the hard limit before dropping privileges. Without
root or that unit limit, ranks often keep a small hard memlock (often 8 MiB);
UCX then spins in ibv_reg_mr and MPI_Init never finishes. Register
--address with a hostname/IP other nodes can reach (not a name that
resolves to 127.0.0.1, and not an internal name if peers use a different
FQDN). On Ubuntu libpmix2t64, set hash GDS on the server (spurd
environment) as well as in rank env.sh (see below).
[Service]
User=root
LimitMEMLOCK=infinity
Environment=PMIX_MCA_gds=hash
Start or restart daemons after install or upgrade (controller first, then agents). Example on an agent:
pkill -x spurd || true
nohup spurd --listen=[::]:6818 --config=/etc/spur/spur.conf \
--controller http://controller.example:6817 >> /var/log/spurd.log 2>&1 &
Agent MPI environment (before the first job)#
Spur’s rank wrapper sources ${HOME}/spur/mpi/env.sh if that file exists
(submitter’s home on the agent). Install it on every compute node; homes
are often not shared. Ubuntu libpmix2t64 needs hash GDS on both
spurd and the ranks — default shmem GDS fails against Spur’s PMIx server.
On multi-NIC hosts, pin Open MPI TCP to the IPv4 interface that reaches peer
agents (ip -br addr). Unpinned btl=tcp may pick docker, overlay, or
IPv6-only NICs; several tasks per node across two nodes then hangs in
MPI_Init even though a single-node or 1-task-per-node run may succeed.
GPU collectives need the same treatment one layer up. RCCL chooses its transport
independently of Open MPI, so OMPI_MCA_btl_tcp_if_include does not constrain
it, and NCCL_SOCKET_IFNAME must name the same NIC. Left unpinned, the symptom
matches the Open MPI one: one rank per host works and several ranks per host
across two hosts hangs.
mkdir -p "$HOME/spur/mpi"
cat > "$HOME/spur/mpi/env.sh" <<'EOF'
export PMIX_MCA_gds=hash # Ubuntu libpmix2t64; omit if GDS already works
export OPAL_PREFIX=/opt/openmpi # prefix used to build the application
export LD_LIBRARY_PATH="${OPAL_PREFIX}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export OMPI_MCA_btl_tcp_if_include=eth0 # fabric NIC that reaches peer agents
export OMPI_MCA_oob_tcp_if_include=eth0
export NCCL_SOCKET_IFNAME=eth0 # RCCL sockets; the same NIC
EOF
Build the application on each agent with that prefix’s mpicc (the
controller often has no MPI compiler). The binary path in the batch script
must exist on every allocated node.
Verify MPI wiring from a host with CLI access after env.sh and
the MPI binary are in place:
scontrol ping
sinfo # all agents idle/ready
srun --mpi=list # expect: none, pmix
srun --mpi=pmix -n4 /path/to/hello_mpi # single-node smoke test
srun --mpi=pmix -N2 -n4 /path/to/hello_mpi # 4 ranks total (2 per node)
# Stronger multi-node check (same layout as a typical sbatch):
# srun --mpi=pmix -N2 -n8 /path/to/hello_mpi
# or: sbatch with -N 2 --ntasks-per-node=4 --mpi=pmix then srun --mpi=pmix ./hello_mpi
Multi-node --mpi=pmix requires a uniform task layout: -n must be
evenly divisible by -N (same number of tasks on every node). For example,
-N2 -n4 (two tasks per node) is valid; -N2 -n3 is rejected at prepare
time because ranks cannot be split evenly across nodes.
For multi-node srun, the command path and any binaries or scripts it
execs must exist at the same path on every participating agent (for
example /tmp/hello_mpi on each node, not only on the submission host).
Expected hello_mpi output for -n4: four lines with rank=0 …
rank=3 and size=4 on each.
Upgrade / rollout#
Pick the new nightly (or pinned) tarball on GitHub or artifactory.
Stop
spurctldandspurdon each host before replacing binaries (SCP orinstall.shfails with “text file busy” while daemons are running). When copying manually, stage to/tmpthenmvinto~/spur/bin/.Run
install.shwith the sameINSTALL_DIRon the controller and every agent (replaces binaries andspur_mpi_pmix.sotogether).Restart
spurctldon the controller, thenspurdon each agent.Re-run the smoke tests above before returning the cluster to users.
Keep spurctld, spurd, and spur_mpi_pmix.so on the same build
across the cluster during an upgrade.
Architecture#
``spurd`` loads
spur_mpi_pmix.soand callsPMIx_server_initwhen a job withmpi = pmixis launched.The plugin registers a namespace (
spur.<job_id>) with Slurm-style topology metadata (PMIX_NODE_MAP,PMIX_PROC_MAP, job/local size keys,PMIX_LOCAL_PEERS,PMIX_LOCALLDR,PMIX_TMPDIR), then serves PMIx to application processes.For
-n > 1,spurdwraps the user command in a bash script that forks one process per rank. Each child receives a fullPMIx_server_setup_forkenvironment (Slurmmpi_p_slurmstepd_taskparity) viaspur_mpi_pmix_setup_fork_envin the plugin.The wrapper exports
PMIX_SERVER_URI4/PMIX_SERVER_URI3aliases. Slurm-compatibleSLURM_*twins remain set (same as Slurm under--mpi=pmix).
The embedded PMIx server registers fence_nb once at PMIx_server_init.
Single-node jobs never call it (OpenPMIx GDS handles modex locally). Multi-node
jobs use fence_nb to exchange modex blobs over TCP between agents (peer
addresses come from the controller allocation). The plugin does not
finalize/reinit PMIx when switching between single- and multi-node jobs on the
same agent.
Multi-node bootstrap uses a two-phase controller dispatch:
PreparePmix — each agent starts its PMIx server, binds the modex TCP listener, and verifies peer reachability before any rank exec.
LaunchJob with
pmix_prepared=true— joins the prepared namespace and starts user processes.
If prepare fails on any node, the controller rolls back with ReleasePmix on
agents that succeeded and evicts the job with a descriptive state_reason.
Partial launch failures also release prepared-but-unlaunched agents.
Modex timeouts are configurable under [mpi] (seconds; 0 = built-in default):
modex_connect_timeout_secs = 5
modex_fence_timeout_secs = 120
modex_verify_timeout_secs = 30
Build the plugin from source (fallback)#
Use this only when the tarball plugin cannot load on your agents (missing
libpmix.so, undefined PMIx symbols, or libpmix version skew). Build on the
same OS/glibc as the agent, linking against the agent’s libpmix.so.
With libpmix development files (pkg-config pmix):
cargo build --release -p spur-mpi-pmix
sudo install -D target/release/spur_mpi_pmix.so /usr/lib/spur/spur_mpi_pmix.so
If pkg-config pmix is unavailable, compile on the agent against that
node’s libpmix.so. Include and link the same tree. Mixing vendor
Open MPI PMIx headers (/usr/mpi/gcc/openmpi-*/include, often PMIx 3.x)
with distro libpmix.so.2 (OpenPMIx 5) produces a plugin that dlopen``s
then fails at runtime (``pmix_min_version, missing symbols, or
pmix_value_load crashes). Prefer Debian-style OpenPMIx:
/usr/lib/x86_64-linux-gnu/pmix2/include(Debian/Ubuntulibpmix-dev)
Example (adjust -I and libpmix paths for your agent):
gcc -fPIC -Wall -O2 -shared -o spur_mpi_pmix.so \
c/pmix_server.c c/modex_exchange.c \
-Ic -Iinclude \
-I/usr/lib/x86_64-linux-gnu/pmix2/include \
/usr/lib/x86_64-linux-gnu/pmix2/lib/libpmix.so.2 \
-pthread -Wl,-rpath,/usr/lib/x86_64-linux-gnu/pmix2/lib
sudo install -D spur_mpi_pmix.so /usr/lib/spur/spur_mpi_pmix.so
Copying a plugin built on a mismatched dev environment (wrong glibc or
libpmix) can crash spurd at dlopen time.
Runtime requirements#
OpenPMIx on the agent (plugin links
libpmixat load time).Open MPI runtime libraries matching the application build (
mpicc/LD_LIBRARY_PATH/OPAL_PREFIX). Spur does not invokempirunfor--mpi=pmix(Slurm direct-launch parity).Application binaries built against the same Open MPI install you use at runtime (consistent
LD_LIBRARY_PATH/OPAL_PREFIX).
plugin_dir must match where the .so lives. The code default is
/usr/lib/spur. install.sh with INSTALL_DIR=/opt/spur/bin uses
/opt/spur/lib/spur — set plugin_dir to that path, or copy the plugin
to /usr/lib/spur.
Complete [mpi] plus env.sh, hash GDS, and (on multi-NIC hosts) TCP
interface pinning before the first job; see Agent MPI environment above.
Submit PMIx jobs#
Finish the agent environment first. Then:
srun --mpi=pmix -n4 ./hello_mpi
srun --mpi=pmix -N2 -n8 ./hello_mpi # 4 ranks/node × 2 nodes; needs TCP NIC pin on multi-NIC hosts
Typical batch script (binary must exist at this path on every allocated node):
#!/bin/bash
#SBATCH -J hello-mpi
#SBATCH -N 2
#SBATCH --ntasks-per-node=4
#SBATCH -t 01:00:00
#SBATCH --mpi=pmix
cd "$SLURM_SUBMIT_DIR"
srun --mpi=pmix ./hello_mpi
Submit from a host with PATH and SPUR_CONTROLLER_ADDR set. Look for
spur-<jobid>.out on an allocated agent if homes are not shared. Expect
eight rank=… size=8 lines.
Inside an interactive allocation (salloc), enable PMIx per step:
srun --mpi=pmix -n4 ./hello_mpi
Minimal hello_mpi (build on the agent with mpicc):
#include <mpi.h>
#include <stdio.h>
int main(int argc, char **argv) {
int rank, size;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
printf("rank=%d size=%d\n", rank, size);
MPI_Finalize();
return 0;
}
Expected result for -n4: four lines with rank=0 … rank=3 and
size=4 on each.
Application scripts should avoid:
OMPI_MCA_ess=env— conflicts with Spur’s embedded PMIx server.Forcing
OMPI_MCA_pmix=ext3xon Open MPI 4.1 (use the defaultpmix3xcomponent, or omit the variable).Mixing library paths from different Open MPI installations.
Putting distro
libpmixon the application’sLD_LIBRARY_PATHwhen the binary was built against vendor Open MPIpmix3x(and the reverse).
Operational notes#
Set
SPUR_MPI_DEBUG=1inspurdenvironment for plugin debug logs.Each agent holds at most 64 active PMIx namespaces; additional concurrent
--mpi=pmixjobs on the same node fail until a job finishes.Single-node and multi-node PMIx jobs can run back-to-back on the same agent (for example a single-node smoke test followed by a multi-node job). Single-node jobs use local GDS modex; multi-node jobs use TCP modex via
fence_nb.Multi-node
--mpi=pmixis not supported on K8s virtual agents (thespur-k8sin-cluster agent returnsUnimplementedforPreparePmix).Multi-node
--mpi=pmixrequires agent addresses in the cluster registry to be reachable from every node in the allocation. Hostnames and IPv4 literals are resolved via DNS; modex TCP listens on a port hashed from the job and step ids into16819-24818. Unrelated steps can hash onto the same port. The step that loses the race fails to start and logsmodex bind port <port> for job <j> step <s> failed: Address already in use; the step already holding the port keeps running, because every modex frame is checked against the listener’s job and step before it is acted on.Modex timeouts travel with
PreparePmixinPmixLaunchPlan(0= agent[mpi]defaults). Keep[mpi]modex timeout settings identical across all agents when not passing explicit values.Multi-rank
--mpi=pmixsteps use the same per-rank fork +setup_forkpath as batch jobs. Spur CPU bind (--cpu-bind) and per-rank GPU partitioning (SPUR_JOB_GPUS) apply through the fork wrapper.Batch stdout (
spur-<jobid>.out) is often written on an allocated agent, not the submission host, when those filesystems are not shared.A job may sit in
COMPLETINGafter ranks have already printed andExitCode=0:0. Check the.outfile before assumingMPI_Inithung. A hang looks like ~100 % CPU on the rank processes and an empty.outuntil cancel.
Submitting Jobs#
cat > train.sh << 'EOF'
#!/bin/bash
#SBATCH --job-name=distributed-training
#SBATCH -N 2
#SBATCH --ntasks-per-node=8
#SBATCH --gres=gpu:mi300x:8
#SBATCH --time=4:00:00
torchrun \
--nnodes=$SPUR_NNODES \
--node_rank=$SPUR_NODE_RANK \
--master_addr=$(echo $SPUR_PEER_NODES | cut -d: -f1) \
--master_port=29500 \
--nproc_per_node=$SPUR_TASKS_PER_NODE \
train.py
EOF
spur submit train.sh
Environment Variables#
Each node in a multi-node job receives:
Variable |
Example |
Description |
|---|---|---|
|
|
Job ID |
|
|
Total nodes in allocation |
|
|
This node’s starting task index |
|
|
All nodes in the allocation |
|
|
CPUs allocated on this node |
GPU Isolation#
Spur restricts a job to its allocated GPUs in two layers:
Visibility. The allocated device ordinals are exported into the standard GPU runtime variables —
ROCR_VISIBLE_DEVICES,CUDA_VISIBLE_DEVICES, andGPU_DEVICE_ORDINAL. This layer is advisory: a job that overwrites them sees every GPU on the node again. A rootspurdadditionally runs the batch payload in a mount namespace where/dev/driis replaced by a tmpfs carrying only the job’s own render nodes, so a job allocated no GPUs finds that directory empty. Every mount there is best-effort and none of it is a boundary — that is the next layer’s job.Access. With
[cgroup] constrain_devices(on by default) the job’s cgroup carries a default-deny BPF device filter, so opening the device node of a GPU the job was not allocated fails withEPERMin the kernel, whatever the environment says.
The filter is attached to the job’s cgroup, so it covers every process the agent
starts for the job: the batch payload, srun steps, spur exec, and
interactive attach alike. Installing it needs CAP_BPF and CAP_NET_ADMIN (or
CAP_SYS_ADMIN) — a cgroup-device program is a net-admin program type, so CAP_BPF
alone is not enough. An agent without them logs a warning and runs the job with no
device isolation, unless [cgroup] required is set, which refuses the job instead. See
What is not contained yet.