A transparent wrapper around uv that makes it behave correctly
on an HPC cluster: no home-directory quota consumption, no architecture mismatches between login
and compute nodes, and no shared state between users.
It is a single bash script. Users type uv and uvx and get the real thing.
$ module load uv
$ uv --version
uv 0.12.2
$ uv-manager status
uv-manager: 0.3.0
invoked as: uv-manager (/apps/external/uv/main/bin/uv-manager)
architecture: x86_64
state root: /anvil/scratch/x-lentner/.uv (from UVM_ROOT)
arch root: /anvil/scratch/x-lentner/.uv/x86_64
selected version: versions/0.12.2
...- Why this exists
- What the wrapper does
- For users
- For administrators: deployment
- Adapting to your site
- Scratch purges and what uv does not notice
- Design notes
- Automation: Globus Compute endpoints
- Troubleshooting
- Reference
uv suits HPC well: a single static binary, no bootstrap interpreter, fast enough that
provisioning an environment inside a job prologue is reasonable, and a lockfile format that makes
runs reproducible. Users want it, and we want them to have it.
Out of the box, though, uv makes three assumptions that a cluster violates.
By default uv writes to:
| What | Default location |
|---|---|
The uv binary (standalone installer) |
~/.local/bin |
| Download / wheel cache | $XDG_CACHE_HOME/uv → ~/.cache/uv |
Tool virtual environments (uv tool install) |
~/.local/share/uv/tools |
| Tool executables | ~/.local/bin |
Managed Python interpreters (uv python install) |
~/.local/share/uv/python |
All of those are on /home. At Purdue that is a 25 GB quota that cannot be raised. A single
uv-managed CPython is about 150 MB unpacked, and a cache holding a few PyTorch and JAX builds runs
to tens of gigabytes. Users fill their home directory and then find out about it when a job dies
overnight on a failed write, or when their shell stops working.
Home is also the wrong filesystem for this work even when it fits. It is provisioned as medium-performance, snapshotted, non-purged space. Putting the metadata traffic of a hundred ranks resolving dependency trees onto it is slow, and it degrades the filesystem for everyone else.
This is the assumption that costs people jobs. Clusters are increasingly heterogeneous: login
nodes and most partitions are x86_64, while newer accelerated partitions are aarch64
(Grace-Hopper, GB10, and similar).
A user logs into the login node, runs uvx vllm or uv sync, gets a working environment, and
submits to an aarch64 partition. uv itself, the managed interpreter, every compiled wheel in
the venv, and every tool executable in ~/.local/bin are the wrong architecture. The failure is
Exec format error, typically thousands of lines into a job log, after the allocation has been
charged.
Nothing in uv guards against this, because on a normal machine it cannot happen. Partitioning
per-user state by architecture is the wrapper's main job.
uv's installer edits shell startup files. Its tool executables are only useful if a directory
gets added to PATH. Neither assumption holds in a batch job, a systemd user service, or a
Globus Compute endpoint spawning workers.
The obvious alternative is a site-wide module load uv backed by one blessed binary and, perhaps,
shared environments in /apps. We chose not to:
- Version alignment belongs to the user. The point of
uvis that a project pins its own toolchain. A site-blessed version becomes something users work around as soon as it lags what apyproject.tomlexpects. It also conflicts with the Globus Compute case below, where the client's versions dictate what has to be provisioned. - Shared environments carry ongoing cost. They need a curation policy, a deprecation policy,
a permissions model, and someone to own all three.
uvbuilds most environments from a warm cache in seconds. - Shared mutable state causes accidents. One user's
uv tool installshould not be able to affect another's.
So every user gets their own uv and their own environments, and the site provides a launcher
that puts them in the right place. That is the whole scope of the project.
bin/uv-manager is the script. bin/uv, bin/uvx and bin/uvm are symlinks to it, and it
picks its mode from basename $0, the same way real uv does.
On every invocation it:
-
Resolves the state root.
$UVM_ROOTif set (normally by the modulefile), otherwise a cascade over conventional site variables. It refuses to run if neither yields a writable directory. See Adapting to your site. -
Appends the architecture (
uname -m) and derives the tree:$UVM_ROOT/ ├── bin/ architecture-neutral trampolines ← safe on PATH └── <arch>/ ├── versions/<ver>/ uv + uvx, one directory per version ├── current -> versions/<ver> ├── bin/shims/ tool executables (UV_TOOL_BIN_DIR) ├── bin/python-shims/ managed python links (UV_PYTHON_BIN_DIR) ├── cache/ download + wheel cache (UV_CACHE_DIR) ├── tools/ tool virtual environments (UV_TOOL_DIR) └── python/ managed interpreters (UV_PYTHON_INSTALL_DIR) -
Provisions
uvon first use for that architecture, into a version-keyed directory, withcurrentswapped atomically. Concurrent invocations serialize on an atomicmkdirlock that releases itself on signals and breaks itself when abandoned. -
Exports the
UV_*storage variables andexecs the real binary. Because itexecs, exit codes and signals are the realuv's. The wrapper adds roughly 7 ms per invocation on top ofuvitself, which matters only if you are calling it tens of thousands of times. -
Intercepts
uv self update, which cannot work for an unmanaged install, turning it into a re-provision. -
Maintains architecture-neutral trampolines so executables from
uv tool installanduv python installare reachable from a singlePATHentry on every node type.
UV_UNMANAGED_INSTALL is doing several jobs at once here. Reading
install.sh confirms it sets NO_MODIFY_PATH=1 and
INSTALL_UPDATER=0, and that INSTALL_UPDATER=0 gates the receipt write. So:
- No shell startup file is edited.
- No receipt is written to
$XDG_CONFIG_HOME/uv/uv-receipt.json, so provisioning does not clobber the bookkeeping of a user's own personaluvin~/.local/bin. The two coexist. - The binary's own
uv self updateis disabled as a side effect, which is why we intercept it. - The installer verifies an inlined SHA-256 and places binaries with
mvwithin the destination directory, an atomic rename, so re-provisioning under a running process is safe.
module load uv
uv init myproject && cd myproject
uv add numpy
uv run python -c 'import numpy; print(numpy.__version__)'
uvx ruff check . # ephemeral tool, nothing installedEverything is ordinary uv. The additions live under uv-manager:
| Command | Effect |
|---|---|
uv-manager status |
Every resolved path and the selected version. Useful in a support ticket. |
uv-manager doctor |
Check for a partially purged or damaged tree. See below. |
uv-manager versions |
List uv versions installed for this architecture. |
uv-manager install [VER] |
Select a version, downloading it if absent. With no version, re-resolve latest from the network. |
uv-manager trampolines |
Regenerate the neutral executable shims. |
uv-manager clean [cache|arch|all] --yes |
Remove part of the tree. |
uvm is a short alias for uv-manager, so uvm status and uvm doctor work too.
Three things worth putting in your site documentation:
First use on each architecture downloads uv. It takes a few seconds and needs outbound
HTTPS. If your compute nodes have no egress, users must run any uv command once on a node of the
same architecture that does.
A project's .venv is still the user's to manage. The wrapper relocates uv's own storage. It
does not set UV_PROJECT_ENVIRONMENT, so uv sync still creates ./.venv. If the project lives
in $HOME, that venv counts against home quota and is architecture-specific:
export UV_PROJECT_ENVIRONMENT=".venv-$(uname -m)"Scratch is purged, and uv will not notice. See
below. This is the one remaining problem the
wrapper cannot fully solve.
/apps/external/uv/main/
└── bin/
├── uv-manager the script
├── uv -> uv-manager
├── uvx -> uv-manager
└── uvm -> uv-manager short alias for uv-manager
git clone https://github.com/purduercac/uv-manager /apps/external/uv/mainUse git clone or rsync -a so the symlinks stay symlinks. cp -r without -a dereferences
them into four independent copies, which still dispatch correctly (mode comes from the invoked
name, not from whether it is a symlink) but will drift apart on the next update.
Do not install this as ~/.local/bin/uv. That is the standalone installer's default
UV_INSTALL_DIR, so the wrapper would overwrite, or be overwritten by, a user's own real uv.
A complete, commented modulefile is in
share/modulefiles/uv/main.lua. The essential part:
local prefix = "/apps/external/uv/" .. myModuleVersion()
local scratch = os.getenv("CLUSTER_SCRATCH") or os.getenv("RCAC_SCRATCH")
or os.getenv("SCRATCH")
prepend_path("PATH", pathJoin(prefix, "bin"))
if scratch ~= nil then
local root = pathJoin(scratch, ".uv")
setenv("UVM_ROOT", root)
prepend_path("PATH", pathJoin(root, "bin")) -- neutral trampolines
endEvery path this modulefile exports must be architecture-neutral. Getting this wrong is the main deployment hazard, so the mechanism is worth stating:
A modulefile is evaluated on whatever node runs module load, normally an x86_64 login node.
Slurm's default --export=ALL then copies that environment verbatim onto compute nodes that may
be aarch64. Something like setenv("UV_CACHE_DIR", scratch .. "/.uv/x86_64/cache") would be
wrong for every aarch64 job, and would fail silently.
UVM_ROOT is safe because the architecture is appended by the wrapper at exec time, on the node
that is actually running. So is $UVM_ROOT/bin, which holds trampolines that re-resolve
uname -m when invoked. Do not inline the UV_* variables into the modulefile.
Two other deliberate omissions:
- No
family(). Declaringfamily("python")would force-unload a user'scondaorpythonmodule.uvcoexists with them. - No
XDG_CONFIG_HOMEoverride. That is where~/.config/uv/uv.tomllives: index URLs, credentials, mirrors. Redirecting it would change dependency resolution, not just storage.
For index mirrors, link-mode or python-downloads, prefer uv's own system configuration file,
/etc/uv/uv.toml. Per uv's docs,
system config merges with the user's rather than replacing it. Do not use UV_CONFIG_FILE, which
replaces user config entirely. If you cannot manage /etc on compute images, set the
corresponding UV_* variables instead; etc/uv-manager.conf.example
lists the ones that matter on a parallel filesystem, with reasons.
If compute nodes lack outbound HTTPS — worth verifying rather than assuming — do this once per architecture at deployment time:
for host in <x86-login> <aarch64-node>; do
ssh "$host" 'module load uv && uv --version && uv-manager status'
doneFor a site with no egress at all, mirror the Astral release assets and set UVM_INSTALL_URL to
your mirror; the wrapper fetches <base>/install.sh or <base>/<version>/install.sh.
module load uv
uv-manager status # every path under scratch, none under $HOME
uv-manager doctor
du -sh ~/.cache/uv ~/.local/share/uv 2>/dev/null # should not exist
# flock works on your scratch filesystem; uv requires it
python3 -c 'import fcntl,os; f=os.open(os.environ["UVM_ROOT"]+"/.t",os.O_CREAT|os.O_RDWR); fcntl.flock(f,fcntl.LOCK_EX); print("flock OK")'
# the arch split is real
srun -p <aarch64-partition> --pty bash -lc 'module load uv && uv-manager status'Almost everything is one variable.
Set UVM_ROOT from your modulefile, from /etc/profile.d, or from a user's own shell profile.
If UVM_ROOT is unset, the wrapper tries, in order:
$CLUSTER_SCRATCH $RCAC_SCRATCH $SCRATCH $PSCRATCH $WORK $PROJECT
appending /.uv to the first that names an existing, writable directory. Adjust the
uvm_candidates array near the top of bin/uv-manager if your site uses something else:
| Site | Variable |
|---|---|
| Purdue RCAC (community) | $CLUSTER_SCRATCH, $RCAC_SCRATCH |
| Purdue Anvil | $SCRATCH, $PROJECT/$WORK |
| NERSC | $PSCRATCH, $SCRATCH, $CFS |
| TACC | $SCRATCH, $WORK, $WORK2 |
| OLCF | $MEMBERWORK/<proj>, $PROJWORK/<proj> |
Adjusting the array patches the deployed script, and that patch cannot coexist with an update:
git pull aborts, rsync -a overwrites it silently. Somebody re-applies the edit after every
update, or every user drops back to the stock cascade. Setting UVM_ROOT has no such cost.
There is deliberately no /tmp fallback. If nothing resolves, the wrapper prints every candidate
it tried along with why each failed, and exits non-zero. A silent fallback to node-local storage
would mean re-downloading uv on every node of every job, a cold cache everywhere, an egress
requirement on every node, and environments that disappear at job end. The contexts where the
variable is most likely to be missing are the automated ones, where nobody is watching.
uname -m is the default, and it is the right granularity for the uv binary and for uv-managed
CPython builds. It runs on every invocation, including inside loops calling uv run thousands of
times, so anything you replace it with should stay cheap. Override with UVM_PLATFORM. Sites that
may need a finer key: glibc skew between login and compute images, musl-based partitions, or
x86-64-v2/v3/v4 levels if users build wheels from source across a mixed fleet.
uname -m reports arm64 on macOS and aarch64 on Linux, so a test deployment on a Mac produces a
different key.
flock must work. uv takes flock-based exclusive locks on the cache, the managed-Python
directory, the tool directory, and the project environment. On a Lustre mount without flock,
uv fails outright:
Could not acquire lock for `.../python` at `.../python/.lock`: Function not implemented (os error 38)
uv has no bypass for this today
(astral-sh/uv#13626), and UV_LOCK_TIMEOUT does
not help when flock is absent entirely. NFS locking is separately known to be unsound across
client/server boundaries (#18073).
The wrapper's own provisioning lock uses mkdir instead, which is atomic on Lustre, GPFS and NFS
and needs no flock.
Link mode will fall back to copying. uv defaults to clone (reflink), which Lustre, GPFS and
NFS do not support, then tries hardlink, which fails across filesystems. With the cache under
UVM_ROOT and a project .venv on home, users see Failed to hardlink files; falling back to full copy on every install. UV_LINK_MODE=copy makes that explicit and silences the warning;
keeping the venv on the same filesystem as the cache actually fixes it.
Consider UV_COMPILE_BYTECODE=1 and a real TMPDIR. Without the first, the first import from
every rank of a large job writes .pyc concurrently into the same directory, which is a metadata
storm on Lustre. Without the second, building torch or mpi4py from source in a small tmpfs
/tmp fails with an error that does not point at the cause.
Scratch is purged per file on access and modification time: Anvil at 30 days with no warning email,
most Purdue community clusters at 60. The natural question is whether uv re-hydrates gracefully
afterwards. It does not, and the failure is quiet. Tested directly against uv 0.12.2:
| What is lost | What happens |
|---|---|
| Cache only | Fine. Re-downloads on next use. |
| An entire tool environment | uvx <tool> works, since uv rebuilds it. But the executable left in bin/shims by uv tool install becomes a dangling symlink and is not repaired. |
A tool's uv-receipt.toml only |
uv tool list warns Ignoring malformed tool; uv tool run quietly falls back to an ephemeral environment. |
| Individual files inside an environment | uv performs no integrity check on an environment it believes is installed. It execs it, and the user gets a Python ImportError traceback. |
| Files inside a managed interpreter | Same. Deleting lib/python3.12/json produced ModuleNotFoundError: No module named 'json' from uv's own interpreter probe, and broke the whole managed-Python subsystem. |
Partial loss happens in practice, not just in principle. Because purge is keyed per file on access
time, a hot module's __init__.py gets its atime refreshed on every run while tests/,
*.dist-info and rarely-imported submodules in the same environment go untouched for a month. An
environment used weekly can lose its cold files while the hot ones survive.
How much this matters depends on your mount options, which is worth checking. With noatime, atime
never updates, the purge falls back to mtime, and the whole tree ages uniformly, which is the
benign case. With relatime, or a GPFS policy engine using atime, partial loss is live.
Mitigations, in order of effectiveness:
- Put
UVM_ROOTon non-purged storage. On Anvil that means$PROJECT, which is what RCAC's documentation already recommends for long-lived Python environments. Scratch is still fine for the cache alone if you want to split them. uv-manager doctordetects what uv does not: dangling shims, missing receipts, damaged managed interpreters, and files missing from inside an environment, found by walking each distribution'sRECORDmanifest. Reasonable to run from a job prologue for anything long-lived.- Prefer
uvxoveruv tool install. Ephemeral environments are keyed off the cache and rebuild cleanly; installed ones accumulate damage. - Watch inodes. Anvil scratch allows 1,000,000 files and community scratch 2,000,000. A
populated
uvtree is a meaningful fraction of that. Runuv cache pruneperiodically.
Rationale for the choices that may look arbitrary.
Dispatch on basename $0. Real uv does the same, so uvx needs no separate code path and
stays correct if Astral changes what uvx means. It also gives wrapper-specific commands a home,
uv-manager, without shadowing uv's own namespace, which matters because uv keeps adding
subcommands (auth, format, check, audit and upgrade are all recent). Adding a name is
one symlink plus one pattern in the case; unrecognised names fall through to uv mode.
UVM_* for the wrapper's own knobs. uv reads dozens of UV_* variables and adds more with
each release, so a name inside that namespace leaves an operator no way to tell what Astral honors
from what this wrapper invented, and a future Astral name landing on one of ours would collide
silently — unrecognised UV_* variables are deliberately passed through untouched. The five
storage variables the wrapper exports keep their UV_* names: those are uv's by right. The two
prefixes are the storage/resolution boundary made visible in the place an operator reads first, the
modulefile.
exec, not a subprocess. Signals, exit codes and process accounting stay as they would be
without the wrapper. An srun uv run ... has to forward SIGTERM correctly at walltime, and a
wrapper that waited on a child would have to reimplement that. The exceptions are uv tool and
uv python, which change what needs a trampoline and so must run to completion.
Version-keyed installs with an atomic symlink swap. Makes a pin authoritative rather than advisory, makes switching between downloaded versions instant, and makes rollback trivial.
mkdir for the provisioning lock. Atomic on Lustre, GPFS and NFS, needs no helper binary, and
does not depend on flock, which uv itself requires but which is not enabled everywhere.
Architecture-neutral trampolines. The only way to have one PATH entry that is correct on both
node types. Each is a short sh script that re-resolves uname -m at exec time. They are
generated for the union of names across all architectures, so invoking a tool on an architecture
where it is not installed gives a useful message instead of Exec format error.
Not overriding XDG_CONFIG_HOME. Storage is the wrapper's business; resolution is not.
mkdir -p on every invocation rather than behind a sentinel. It is a handful of metadata
operations, and it repairs a tree that a purge has partially eaten.
The wrapper's second job is backing Purdue's Globus Compute Multi-User Endpoint on Anvil.
Globus Compute has a long-standing alignment problem: the Python minor version and the SDK/Parsl
versions have to match across four hops, from client to MEP to the single-user endpoint the MEP
spawns to the workers. When they do not, failures surface as opaque deserialization errors. Anvil's
MEP handles this by intercepting the client's reported versions and using uv to provision a
matching environment before the UEP starts, plus a default worker_init that provisions worker
environments from a site-level requirements parameter.
For that to work the wrapper has to survive an environment it did not choose:
- A UEP is not spawned from a login shell, so whatever your site sets in
/etc/profile.dmay not be present. SetUVM_ROOTexplicitly in the endpoint configuration rather than relying on inheritance. If it is missing, the wrapper fails with the exact fix rather than falling back to node-local storage. worker_initruns on compute nodes, so egress and pre-warming assumptions apply there.- Pin the version.
UVM_PINis authoritative: it selects which installed versioncurrentpoints at, so pinning to an already-downloaded version costs milliseconds. - Provision once, then run directly. Keep
uv runoff the hot path of a many-rank launch. It takes a lock on the project environment, and N ranks starting at once either serialize on it or, whereflockis unreliable, race.
# worker_init
export UVM_ROOT="${SCRATCH}/.uv"
export UVM_PIN=0.12.2
export PATH="/apps/external/uv/main/bin:$PATH"
export UV_PROJECT_ENVIRONMENT="${SCRATCH}/.venvs/${SLURM_JOB_ID}-$(uname -m)"
uv sync --frozen --quiet
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:$PATH"Start with uv-manager status. It prints every resolved path, where the root came from, and
which version is selected.
"cannot determine where to keep per-user uv state" means UVM_ROOT is unset and no candidate
resolved. The message lists each candidate and why it failed. The usual cause is a batch or
automation context that did not inherit a login shell.
"install failed — no egress from this node?" Pre-warm from a node of the same architecture that has outbound HTTPS.
A tool prints "is not installed for architecture 'aarch64'". That is the trampoline working as
intended. Run uv tool install <package> on that architecture.
ImportError from something that used to work is usually a partial purge. Run
uv-manager doctor.
WARN: The following commands are shadowed by other commands in your PATH: uv uvx during
provisioning is cosmetic. The installer notices that the module's bin/, which holds the wrapper,
precedes its own install directory. That is the arrangement we want.
Disk accounting. du -sh $UVM_ROOT/* breaks down cost per architecture.
| Variable | Effect |
|---|---|
UVM_ROOT |
Base for per-user state. Architecture-neutral; the wrapper appends <arch>. |
UVM_PIN |
uv version to provision and select. |
UVM_PLATFORM |
Override the architecture key. Default uname -m. |
UVM_INSTALL_URL |
Installer base URL, for mirrors. Default https://astral.sh/uv. |
UVM_LOCK_TIMEOUT |
Seconds to wait for the provisioning lock. Default 180. |
UVM_LOCK_STALE |
Seconds after which an untouched lock is broken. Default 600. |
UV_CACHE_DIR, UV_TOOL_DIR, UV_TOOL_BIN_DIR, UV_PYTHON_INSTALL_DIR, UV_PYTHON_BIN_DIR,
all under $UVM_ROOT/<arch>/, plus PATH.
XDG_CONFIG_HOME, UV_CONFIG_FILE, UV_PROJECT_ENVIRONMENT, UV_DEFAULT_INDEX, UV_INDEX,
UV_PYTHON_PREFERENCE, UV_PYTHON_DOWNLOADS, UV_LINK_MODE, UV_COMPILE_BYTECODE, TMPDIR.
The first two would change dependency resolution. The rest are site or user policy; see
etc/uv-manager.conf.example.
bin/uv-manager the script
bin/{uv,uvx,uvm} symlinks to it; mode is chosen by basename
etc/uv-manager.conf.example example site settings, heavily commented
share/modulefiles/uv/main.lua example Lmod modulefile
- uv environment variables
- uv storage locations
- uv configuration files
- Astral standalone installer
- Purdue RCAC documentation
- Globus Compute multi-user endpoints
MIT. See LICENSE.