Per-GPU Compute and Memory Limits with MPS
Give a multi-GPU application a fixed share of every GPU, and pack smaller models into the rest
A large model that spans several GPUs often leaves part of each GPU idle. This guide shows how to cap that model at a fixed share of every GPU it uses and run smaller models, such as embedding or BERT-class servers, in the remainder. It uses CUDA Multi-Process Service (MPS) v3 for compute limits and the cgroup v2 dmem controller for memory limits.
You’ll set up:
- The same compute and memory limit on every GPU an application sees, from one MPS server configuration file.
- A different memory limit on each GPU for the same container, through
nvidia-smi memory-limits.
Every procedure on this page was run on a single-GPU host; the measured results are in Verified Behavior. On a multi-GPU node, the only change is the list of GPU UUIDs or indices you pass.
Requirements
Reference documentation: MPS v3 Interface, MPS v3 Configuration File, and MPS v3 Memory Partitioning.
How the Pieces Fit
- Compute is capped by the namespace’s
active_thread_percentage. It applies to every GPU the client opens. - Memory is capped in one of two ways:
- The namespace’s
pinned_memory_limitis one value for every GPU. - A cgroup
dmemlimit is set separately for each GPU, and is the only way to give one container a different budget on each GPU.
- The namespace’s
- Memory bandwidth is not partitioned. Neither MPS nor
dmemsplits DRAM bandwidth. A memory-bound tenant slows its neighbors no matter how SMs are divided. Interpreting GPU Sharing Results shows the effect on decode throughput.
Same Limits on Every GPU
1. List the GPUs
Use UUIDs everywhere. Ordinals change meaning when CUDA_VISIBLE_DEVICES is set.
2. Write the server configuration
Create mps.toml. Put every GPU the server should own in allowed_devices, comma-separated:
-
Limits apply per GPU. A client of
largecan use 70% of the SMs and 56 GiB on each GPU it opens; the check in step 6 confirms this on every GPU the client sees. Memory sizes use binary units:10Gis 10,737,418,240 bytes. -
The
[servers.shared]limits apply only to the default namespace. A named namespace does not inherit them, and it is not capped by them:- a namespace that sets no limits gets the whole GPU;
- a namespace can set values above the server-level limits.
Set both
active_thread_percentageandpinned_memory_limiton every namespace you create. -
active_thread_percentageis a ceiling, not a reservation. Two namespaces whose percentages add up to more than 100 share SMs.
3. Start the daemon
Use a node-local pipe directory, such as one on the /dev/shm tmpfs. A network filesystem can’t host the daemon’s sockets.
The configuration is applied once at startup. Changes made later with the CLI are not written back to the file.
4. Confirm the server came up
A server whose allowed_devices names a GPU that isn’t on the node is accepted (server create returns success), but the server process exits with no CUDA-capable device is detected and disappears from server list. Any client pointed at it then runs without MPS and without limits. Always check server list and server.log after a start.
namespace list shows the limits each namespace enforces:
5. Run clients in a namespace
A client selects its namespace by pointing CUDA_MPS_PIPE_DIRECTORY at that namespace’s directory:
For a container, mount only the namespace directory, pass the same path, and run as the user that owns the MPS daemon. A container user that differs from the daemon’s owner, including root, gets CUDA_ERROR_NOT_PERMITTED. To serve clients running as other users, start the daemon as root in multi-user mode (--multiuser-server; see nvidia-cuda-mps-control -h).
In the default compute mode, a client with a missing or misspelled pipe directory runs as a normal CUDA process with the whole GPU, and nothing reports an error. Verify every new deployment with the check below.
6. Verify what a client gets
Run this inside the client environment, with the same CUDA_MPS_PIPE_DIRECTORY and GPU selection:
Expect the SM count to be the namespace percentage of the GPU’s SMs, rounded down to a supported count. On a 188-SM GPU, 50% gives 94 and 25% gives 46. Expect the allocatable memory to stop within a few hundred MiB of the pinned limit. That headroom is the client’s own context.
Under an MPS pinned memory limit, cudaMemGetInfo reports the limit as free memory but the whole GPU as total. Frameworks that size memory as a fraction of total, such as vLLM’s --gpu-memory-utilization, must be given the fraction explicitly: for a 56 GiB cap on an 80 GiB GPU, pass 0.65 or less, not 0.9. A dmem limit (next section) is the only cap that cudaMemGetInfo reports as total.
Different Memory Limits per GPU
The namespace pinned_memory_limit is one value for every GPU. To give one container 40 GiB on GPU 0 and 56 GiB on GPU 1, set a cgroup dmem limit per GPU. dmem limits apply to MPS clients too, so you can combine a namespace for the compute limit with dmem for per-GPU memory.
1. Confirm the backend and find each GPU’s key
Each GPU has its own line. nvidia-smi memory-limits resolves the key from -i, so you don’t write these files yourself. Set limits only through nvidia-smi or NVML; writing dmem.* files directly skips the reservation bookkeeping the driver maintains on parent cgroups.
2. Know the command syntax
memory-limitsmust come first.nvidia-smi -i 0 memory-limits ...fails withOption memory-limits is not recognized.- Values are MiB integers,
max, ordefault.--soft-limit=40Gis rejected. -ntakes the full path of the cgroup directory.- The hard limit is enforced at allocation time; an allocation past it returns
CUDA_ERROR_OUT_OF_MEMORY. - The soft limit is a guaranteed share. Above it, a process is borrowing, and MPS can evict it when another tenant within its own soft limit needs the memory. Set soft equal to hard for a strict partition.
- Reading limits needs no root. Setting them does.
3. Pick where to apply the limit
Docker puts each container in its own leaf cgroup. With the default systemd cgroup driver, --cgroup-parent must name a slice (bert0.slice), not a path (/bert0):
The container’s leaf cgroup gets its own dmem.max of max. That changes what the container sees depending on where you put the limit:
A parent slice shared by several containers gives them one shared budget. Use one slice per container.
4. Option A: limit the container’s own cgroup (recommended)
-
Start the container with an idle command so its cgroup exists before the workload does:
-
Find the container’s cgroup:
-
Set a different limit on each GPU, one call per GPU:
On a two-GPU node,
dmem.maxholds one line per GPU: -
Start the workload in the container. It sees each GPU’s limit as that GPU’s total memory:
To start the workload from the container’s own entrypoint instead of docker exec, have the entrypoint wait for a signal, such as a file your launcher creates after step 3.
5. Option B: limit a per-container parent slice
Option B lets the workload start as soon as its container starts, with no wait for a limit, at the cost of the container reporting the full GPU.
-
Create the slice by starting an idle holder container in it. The holder needs no GPU, and it keeps the slice in place until the workload joins:
-
Set the limit on the slice, one call per GPU:
-
Start the workload in the same slice, with its memory budget set explicitly:
Allocations stop at the slice’s limit. cudaMemGetInfo inside the container still reports the whole GPU, so configure the application’s memory budget yourself. For vLLM, set --gpu-memory-utilization below the limit divided by the GPU’s total memory.
6. Remove limits before removing the cgroup
The driver adds each soft limit to the parent cgroups’ reservations. Deleting a cgroup without zeroing its soft limit leaves that reservation on the parents. Before you stop a container or delete a slice, reset the limits on the cgroup you set them on: the container’s cgroup for Option A, or the slice for Option B.
Client-Side Per-GPU Limits
A client can lower its own pinned memory limit per GPU with CUDA_MPS_PINNED_DEVICE_MEM_LIMIT, keyed by ordinal or UUID:
The variable can only lower the namespace’s limit; 0=20G under a 10 GiB namespace still allocates at most 10 GiB.
If any entry names a GPU the process can’t see, the client drops every pinned memory limit, including the namespace’s. Examples are 1=4G in a process with one visible GPU, or a UUID that isn’t present. Its compute limit still applies. Tensor-parallel launchers often narrow CUDA_VISIBLE_DEVICES per worker, which turns a correct node-wide list into an unlimited one. List only the GPUs the process sees, prefer UUID keys, and verify the result. A tenant can also escape its memory limit this way, so use dmem limits where enforcement matters.
Example: One Large Model Plus Small Models on Eight GPUs
To keep a tensor-parallel model at 70% of each of eight 80 GiB GPUs and pack small model servers into the rest:
- Create one server that owns all eight GPUs, a
largenamespace atactive_thread_percentage = "70"andpinned_memory_limit = "56G", and asmallnamespace at"30"and"24G". Set the default namespace’s server-level limits low, so any client that connects without a namespace can’t take a whole GPU. - Point every rank of the large model at
<pipe>/shared/large. - Run one small-model container per GPU, each restricted to its GPU with
--gpus '"device=GPU-<uuid-i>"'and pointed at<pipe>/shared/small. - Where small-model containers need different memory on different GPUs, give each container a
dmemlimit per GPU (Option A).
Expect the large model’s memory-bound decode to slow when the small models are busy. They share DRAM bandwidth even though their SMs are capped.
Verified Behavior
Checked on one NVIDIA RTX PRO 6000 Blackwell (188 SMs, 96 GB) with driver 615.71.09, CUDA 13.4, Linux 7.0, systemd 255, and Docker 29.8 (systemd cgroup driver). Every case was run with exclusive use of the GPU and an idle-GPU check before each run. The dmem cases ran twice with identical results. Multi-GPU behavior follows from the per-GPU keys shown above; it was not run on multi-GPU hardware.