hands-on labon a real NVIDIA GPU

GPU Sharing: Streams, MPS, MIG, and the Real Cost of Contention

Measure four ways to share a single GPU, CUDA streams, multi-process time-slicing, MPS, and MIG, and write the production artifacts (start scripts, k8s device-plugin ConfigMaps, MIG geometries) that turn 15%-utilized fleets into 80%-utilized ones.

time
45 min
checked steps
4
level
Advanced
setup
None

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit45 min · 4 stepsSession running
2 / 4 steps passingMPS: the production artifact · step 3 of 4
lab.ipynb

Step 3 — MPS: the production artifact

You can't run MPS in WSL2 or Docker Desktop (needs a Linux host with full kernel driver). But you absolutely will write these scripts on a real cluster. The pieces:

[ ]:
from IPython.display import Code # ------------------------------------------------------------------# MPS control-daemon start script — runs on the GPU host at boot# Lives at workspace/scripts/start_mps.sh so systemd / a DaemonSet can exec it.# ------------------------------------------------------------------mps_start_script_path = '/home/labuser/workspace/scripts/start_mps.sh'with open(mps_start_script_path) as f:    mps_start_script = f.read() Code(filename=mps_start_script_path, language='bash')
[ ]:
# ------------------------------------------------------------------# Per-client environment — each CUDA process sets these to join MPS# ------------------------------------------------------------------mps_client_env = {    'CUDA_VISIBLE_DEVICES':        '0',               # GPU index the MPS server is on    'CUDA_MPS_PIPE_DIRECTORY':     '/tmp/nvidia-mps', # where the control socket lives    'CUDA_MPS_LOG_DIRECTORY':      '/var/log/nvidia-mps',    'CUDA_MPS_ACTIVE_THREAD_PERCENTAGE': '25',        # this client gets ≤25% of SMs (so 4 fit)    # Per-client memory cap — for a 40GB A100 split 4 ways, cap at 10GB each.    # The value is a string like '10G' or just bytes.    'CUDA_MPS_PINNED_DEVICE_MEM_LIMIT': '0=10G',}print("=== Client env (e.g. in Kubernetes pod spec) ===")for k, v in mps_client_env.items():    print(f"  {k}={v}") # ------------------------------------------------------------------# Shutdown — graceful stop so client contexts are cleanly released# ------------------------------------------------------------------mps_shutdown_cmd = 'echo quit | nvidia-cuda-mps-control'print(f"\nShutdown: {mps_shutdown_cmd}")print("(Then: nvidia-smi -i 0 -c DEFAULT   to restore normal compute mode)")
[ ]:
lab.check(3)
step 3 of 4, replayed: MPS: the production artifact. The code, the output and the check are the lab's own.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    In-process sharing: CUDA streams

    read this step below ↓

  2. 2

    Multi-process sharing WITHOUT MPS: the hidden penalty

  3. 3

    MPS: the production artifact

  4. 4

    Kubernetes: time-slicing, MPS, and MIG configs

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: In-process sharing: CUDA streams

A CUDA stream is an ordered queue of kernel launches and memcpys. Two kernels on two different streams may execute concurrently on the GPU if the hardware has SMs to spare. Two kernels on the same stream run strictly in order.

The default stream (stream 0) serializes everything. That's fine for simple scripts, but inside a serving process that handles multiple simultaneous requests you want overlap — image preprocessing on stream A while the previous batch's forward pass completes on stream B.

Why can't two kernels always overlap? A single large GEMM uses 100% of SMs for its duration — there's no room. Overlap only happens when at least one kernel leaves SMs idle (small batch, memory-bound, or launch-bound). That's the common case for serving: lots of small kernels.

Below: launch two independent 1024×1024 GEMM chains on two streams; compare to running them back-to-back on one stream.

Setup — two independent workloads

Two GEMM chains that share no data. Because there's no data dependency between them, the CUDA runtime is free to overlap their kernels if we give it the hint (a separate stream per chain). Warm-up is important — the first few launches pay JIT and cache setup costs that would otherwise dominate the measurement.

import torch, time

device = 'cuda'
N = 1024
ITERS = 200

def gemm_chain(x, y, iters=ITERS):
    for _ in range(iters):
        x = x @ y
    return x

# Two independent workloads that don't share data — nothing forces ordering.
a1 = torch.randn(N, N, device=device)
a2 = torch.randn(N, N, device=device)
b1 = torch.randn(N, N, device=device)
b2 = torch.randn(N, N, device=device)

# Warm up CUDA kernels so the measurement isn't dominated by first-launch JIT
for _ in range(3):
    gemm_chain(a1, a2, iters=10)
    gemm_chain(b1, b2, iters=10)
torch.cuda.synchronize()

Serial baseline — both chains on the default stream

By default, every CUDA call goes on the default stream, and the default stream implicitly synchronizes with every other stream. So two back-to-back chain calls run strictly one after the other — no overlap possible.

# --- Serial: both chains on the default stream, back-to-back ---
torch.cuda.synchronize()
t0 = time.perf_counter()
gemm_chain(a1, a2)
gemm_chain(b1, b2)
torch.cuda.synchronize()
serial_ms = (time.perf_counter() - t0) * 1000

Concurrent — one stream per chain

Putting each chain on its own torch.cuda.Stream tells the runtime: these have no ordering dependency; overlap them if the hardware has capacity. A single 1024×1024 GEMM doesn't saturate all the SMs on a modern GPU, so the second stream can use the spare SMs — giving a real speedup. At larger matrix sizes that do saturate the SMs, the streams converge back to serial.

# --- Concurrent: one stream per chain ---
s1, s2 = torch.cuda.Stream(), torch.cuda.Stream()
torch.cuda.synchronize()
t0 = time.perf_counter()
with torch.cuda.stream(s1):
    gemm_chain(a1, a2)
with torch.cuda.stream(s2):
    gemm_chain(b1, b2)
torch.cuda.synchronize()
streams_ms = (time.perf_counter() - t0) * 1000

streams_result = {
    'serial_ms':  serial_ms,
    'streams_ms': streams_ms,
    'speedup':    serial_ms / streams_ms,
}

print(f"Serial (1 stream):    {serial_ms:7.1f} ms  (both chains back-to-back)")
print(f"Concurrent (2 streams): {streams_ms:5.1f} ms  (overlapped)")
print(f"Speedup:              {streams_result['speedup']:6.2f}x")
print()
print("Interpretation: a single 1024x1024 GEMM leaves some SMs idle on this GPU,")
print("so two concurrent chains exploit the spare capacity. With larger matrices")
print("that fully saturate SMs, the streams version converges to serial.")

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Comfortable with CUDA basics and PyTorch tensors
  • Basic Kubernetes, Deployments, ConfigMaps, resource requests
  • Understanding of processes vs threads and context switching

Exam domains covered

GPU Acceleration & Distributed TrainingModel Deployment & Inference OptimizationAI Infrastructure & Operations

Part of the GPU and Kubernetes track

Lab 17 of 20. GPU scheduling on your own Kubernetes cluster, the GPU Operator, CUDA kernels and profiling.

Questions about this lab

Does time-slicing give me concurrent GPU execution?

No, this is the most common misconception in the space. Time-slicing via the nvidia-device-plugin with replicas: N simply lets N pods schedule onto the same GPU; the driver still runs their CUDA contexts one at a time in a round-robin with ~10 ms switch overhead. You get higher GPU reservation utilization (kubectl describe node shows full subscription) but you do not get parallel SM execution. For actual concurrency inside a single tenant you need MPS; for concurrency with isolation across tenants you need MIG.

When does MPS beat MIG and when does MIG beat MPS?

MPS wins on total throughput when tenants trust each other and workloads have different bottlenecks, MPS merges contexts so the scheduler can interleave memory-bound and compute-bound kernels at the SM level, and CUDA_MPS_ACTIVE_THREAD_PERCENTAGE caps each client's SM share without a hard partition. MIG wins when you need hardware isolation for SLA or compliance reasons, each slice has its own L2, memory, and SMs, so a noisy neighbor literally cannot steal from you. The cost is fixed-shape partitions (1g.5gb, 3g.40gb, 7g.80gb) chosen at provisioning time; you cannot resize a MIG geometry on the fly without reset.

Why does default multi-process sharing without MPS cause a 2× slowdown?

Because two CUDA processes sharing a GPU each need their own CUDA context, and the driver must switch contexts to service each one. Context switches flush caches, save and restore register state, and invalidate SM allocations, so on average each process sees roughly half the wall-clock throughput it would running alone. MPS eliminates this by merging all client contexts into one daemon-owned context, so the hardware scheduler sees a single stream of kernels and can actually overlap them on different SMs.

Does MPS work on WSL2, Docker Desktop, or consumer RTX cards?

MPS needs a Linux host with the GPU in Exclusive_Process compute mode (nvidia-smi -c EXCLUSIVE_PROCESS), plus enough privilege to start the MPS control daemon. That rules out WSL2 and Docker Desktop. It works fine on bare-metal Linux and on Linux containers with --ipc=host access to the MPS pipe directory. Consumer cards (RTX 3090, 4090, 5090) do support MPS in theory, but MIG is a datacenter-only feature, only A100, H100, H200, B100, B200, and some L40S / GH200 variants support hardware partitioning.

How do I pick a MIG geometry for a mixed inference fleet?

Size each partition by the largest model-replica pair you need to fit, then choose the canonical geometry whose partitions match. For a 7-instance layout of small models you'd pick 1g.10gb x7 on an 80 GB A100; for a mix of small and mid-sized models 3g.40gb + 2g.20gb + 2g.20gb makes sense; for a couple of large LLM shards 3g.40gb x2 or 7g.80gb x1. The lab's mig_profiles dict encodes exactly this shape, name, target hardware, partitions list, and the use case each one was designed for. Remember a MIG reset is disruptive, so over-partition slightly in anticipation of workload growth.

Are CUDA streams useful if my process only runs one kernel at a time?

Yes, the hidden win is overlap between compute and memory transfer, not between two compute kernels. Assigning H2D copies, compute, and D2H copies to different streams lets the copy engines and the SMs work in parallel on the same GPU. For an inference server that's batching requests, that overlap alone can recover 20, 30% of wall-clock. Multiple compute streams only help when the kernels are small enough that a single one doesn't saturate all SMs, which is exactly the case for small transformer layers at low batch size.

What you'll build in this GPU sharing lab

GPU sharing is the lever every AI platform team pulls when fleet utilization sits at 15% and the CFO asks why. A single H100 or A100-80GB is aggressively overbuilt for most individual inference replicas, and the gap between 'one pod, one GPU' and 'forty small models on one card at 80% utilization' is worth six figures a month on any serious fleet. But 'GPU sharing' is actually four different products, CUDA streams, naive multi-process time-slicing, MPS (Multi-Process Service), and MIG (Multi-Instance GPU), each with different isolation guarantees, different failure modes, and different Kubernetes wiring. This lab makes you measure all four on the same card: a two-stream GEMM test showing real concurrency overlap, a two-subprocess contention test showing ~2× per-process slowdown without MPS, a full MPS control-daemon configuration, and Kubernetes artifacts for time-slicing ConfigMaps and MIG geometries. You leave with a decision framework, 'pack 40 small inference models onto one A100-80GB: which mode, and what breaks if you pick wrong?', and the production YAML to implement whichever answer you reached. 45 minutes on a real NVIDIA GPU pod we provision.

The measurements matter because the intuitions are wrong. Time-slicing via nvidia-device-plugin with replicas: N does not give concurrent GPU execution, it round-robins CUDA contexts at the driver with ~10 ms context-switch jitter per swap, and kubectl describe node showing full GPU subscription is cosmetic, not real. You'll see a 1.5-2.5× per-process wall-clock slowdown when two processes share the card without MPS. MPS is the production answer inside one trust boundary: nvidia-cuda-mps-control -d runs a daemon that merges all client CUDA contexts into one, the hardware scheduler then interleaves kernels at the SM level, and CUDA_MPS_ACTIVE_THREAD_PERCENTAGE caps each client's SM share. MPS requires Linux with the GPU in EXCLUSIVE_PROCESS compute mode, WSL2, Docker Desktop, and some container runtimes are out. MIG is the hard-isolation answer, available on A100 / H100 / H200 / B100 / B200 / GH200 and some L40S variants: fixed-shape hardware partitions (1g.5gb, 1g.10gb, 2g.20gb, 3g.20gb, 3g.40gb, 7g.80gb) each with their own L2, memory, and SMs. A noisy neighbour literally cannot steal from you, at the cost of static geometries and a disruptive reset to change them. And the quiet win of CUDA streams is compute/transfer overlap, H2D copies, compute, and D2H on separate streams let the copy engines work in parallel with the SMs, recovering 20-30% of wall-clock on batched inference servers.

Prereqs: CUDA basics and PyTorch tensors, basic Kubernetes (Deployments, ConfigMaps, resources.requests/limits), and a mental model of processes vs threads. Preinstalled on the lab pod: PyTorch, the CUDA toolkit, nvidia-smi, the NVIDIA driver. Grading is concrete at every step: stream concurrency must show ≥1.05× speedup, multi-process contention must show ≥1.2× per-process slowdown (capped at 3.5× to catch implausible numbers), MPS configs must reference nvidia-cuda-mps-control -d plus the CUDA_MPS_PIPE_DIRECTORY / CUDA_MPS_LOG_DIRECTORY / CUDA_MPS_ACTIVE_THREAD_PERCENTAGE env vars and the echo quit | nvidia-cuda-mps-control shutdown pattern, and MIG profiles must reference at least one canonical NVIDIA slice shape. The reflection step forces the real-world call: pack 40 small models onto one A100-80GB, time-slicing fails on latency, MPS scales throughput but gives no memory isolation, MIG gives hard isolation at the cost of fixed partitions. There's a right answer, and the numbers in the lab prove it.