GPU job scheduling library
Project description
GPUPilot
Run multiple PyTorch models across multiple GPUs without manually managing device placement, VRAM limits, or scheduling logic.
GPUPilot discovers your GPUs, measures actual VRAM usage when each model loads, and routes inference jobs to wherever there is room — evicting idle models under pressure and retrying once on OOM.
Installation
pip install gpupilot
Requires Python 3.10+, PyTorch, and nvidia-ml-py (NVML bindings).
Quick start
import torch
import torch.nn as nn
from gpupilot import Scheduler
scheduler = Scheduler(headroom_gb=2.0)
@scheduler.model(name="my_model", memory_estimate_gb=0.5)
def load_my_model():
return nn.Linear(1000, 1000)
scheduler.initialize()
result = scheduler.run("my_model", torch.randn(1, 1000))
print(result.shape) # torch.Size([1, 1000])
scheduler.shutdown()
initialize() discovers GPUs via NVML and starts worker threads. run() blocks until the result is ready. Always call shutdown() when done.
Real-world example: faster-whisper on multiple GPUs
faster-whisper uses CTranslate2 under the hood, not nn.Module, so it manages its own device placement. The pattern below defers GPU assignment to the to() call that GPUPilot makes after loading:
import torch
from gpupilot import Scheduler
from faster_whisper import WhisperModel
class WhisperWrapper:
"""Adapts faster-whisper to GPUPilot's loader protocol."""
def __init__(self, model_size: str, compute_type: str = "float16"):
self._model_size = model_size
self._compute_type = compute_type
self._model: WhisperModel | None = None
def to(self, device: str) -> "WhisperWrapper":
# GPUPilot calls .to("cuda:N") after loader_fn() returns.
# ctranslate2 needs device="cuda" + device_index=N separately.
backend, _, idx = device.partition(":")
device_index = int(idx) if idx else 0
self._model = WhisperModel(
self._model_size,
device=backend,
device_index=device_index,
compute_type=self._compute_type,
)
return self
def __call__(self, audio_path: str):
assert self._model is not None
segments, info = self._model.transcribe(audio_path, beam_size=5)
return list(segments), info
# ---- scheduler setup ----
scheduler = Scheduler(
headroom_gb=2.0, # keep 2 GB free per GPU as a safety buffer
num_workers=4, # inference threads (default: 4)
)
@scheduler.model(
name="whisper-small",
memory_estimate_gb=1.0, # rough estimate; actual usage is measured on load
idle_unload_seconds=300, # unload from GPU after 5 min idle
)
def load_whisper_small():
return WhisperWrapper("Systran/faster-whisper-small")
scheduler.initialize()
# Synchronous — blocks until the transcription completes
segments, info = scheduler.run("whisper-small", "interview.mp3")
for seg in segments:
print(f"[{seg.start:.2f}s → {seg.end:.2f}s] {seg.text}")
scheduler.shutdown()
GPUPilot measures actual VRAM delta via NVML after the model loads and warns you if it deviates more than 20% from memory_estimate_gb. Update the estimate if you see that warning.
Multiple models, async dispatch
Register as many models as you like. GPUPilot picks the best GPU for each one and evicts idle models when VRAM is tight.
import torch
from gpupilot import Scheduler
from faster_whisper import WhisperModel
scheduler = Scheduler(headroom_gb=2.0, num_workers=8)
@scheduler.model(name="whisper-small", memory_estimate_gb=1.0, idle_unload_seconds=300)
def load_small():
return WhisperWrapper("Systran/faster-whisper-small")
@scheduler.model(name="whisper-large", memory_estimate_gb=6.0, idle_unload_seconds=600)
def load_large():
return WhisperWrapper("Systran/faster-whisper-large-v3", compute_type="float16")
scheduler.initialize()
# Fire off several jobs without waiting for each one
audio_files = ["clip1.mp3", "clip2.mp3", "clip3.mp3"]
futures = [
scheduler.submit_async("whisper-small", path)
for path in audio_files
]
# Collect results when ready
for path, future in zip(audio_files, futures):
segments, info = future.result(timeout=120)
print(f"{path}: {info.duration:.1f}s detected as {info.language}")
scheduler.shutdown()
submit_async() returns a concurrent.futures.Future immediately. Use future.result(timeout=...) to block on individual results.
Priority jobs
Higher-priority jobs jump ahead in the queue:
# Priority 10 runs before priority 0 (default)
urgent = scheduler.submit_async("whisper-small", "urgent.mp3", priority=10)
normal = scheduler.submit_async("whisper-small", "batch.mp3", priority=0)
Metrics
scheduler.initialize()
# ... run some jobs ...
m = scheduler.get_metrics()
for gpu in m.gpus:
print(f"GPU {gpu.gpu_id} ({gpu.name}): "
f"{gpu.used_vram_gb:.1f} / {gpu.total_vram_gb:.1f} GB used, "
f"loaded: {gpu.loaded_models}")
for inst in m.instances:
print(f" {inst.model_name} on GPU {inst.gpu_id}: "
f"status={inst.status}, active_jobs={inst.active_jobs}, "
f"vram={inst.allocated_vram_gb:.2f} GB")
print(f"Queue depth: {m.queue_depth}")
Configuration reference
Scheduler
| Parameter | Default | Description |
|---|---|---|
headroom_gb |
1.0 |
VRAM to keep free per GPU as a safety buffer |
num_workers |
4 |
Inference worker threads |
poll_interval_s |
1.0 |
NVML monitor refresh interval |
reaper_interval_s |
10.0 |
How often the idle-reaper thread wakes to check for stale instances |
ModelSpec / @scheduler.model()
| Parameter | Required | Description |
|---|---|---|
name |
yes | Unique model identifier |
memory_estimate_gb |
yes | Expected VRAM usage. Used for placement; actual usage is measured and may differ. |
idle_unload_seconds |
no (default 180) |
Unload from GPU after this many idle seconds. None = pinned (never reaped). |
max_concurrency |
no | Cap on simultaneous inference jobs for this model (not yet enforced — v1 roadmap). |
How it works
- Discovery —
initialize()calls NVML to enumerate GPUs and their total VRAM. - Placement — when a job arrives for an unloaded model, the placement engine picks the GPU with the most safe free VRAM (
total − used − reserved − headroom).reserved_vram_gbcloses the race window between placement and NVML seeing the new allocation. - VRAM measurement — the loader wraps
loader_fn()in ameasure_vram_deltacontext that syncs CUDA and reads NVML before and after, storing the true delta on the instance. - Eviction — if no GPU has room, GPUPilot evicts idle instances LRU-first until enough VRAM is freed, then retries placement.
- OOM recovery — if a
torch.cuda.OutOfMemoryErrorescapes inference, GPUPilot unloads the instance and requeues the job once. A second OOM raisesOOMErrorto the caller. - Idle reaper — a background thread unloads instances that have been idle longer than
idle_unload_seconds. Models registered withidle_unload_seconds=Noneare pinned and never reaped.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file gpupilot-0.1.0.tar.gz.
File metadata
- Download URL: gpupilot-0.1.0.tar.gz
- Upload date:
- Size: 18.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ea6b0e71ee83b5bac94c369b1f0977ce890ac1d09aa0042f1646262b94fbbe4b
|
|
| MD5 |
ecbc1b6814fb6c3b06ac945d21f0edea
|
|
| BLAKE2b-256 |
4fd6b2aec2280deb0973e1a191eed8ab64a35e62fa8a0c6628bd32ce46a9e731
|
File details
Details for the file gpupilot-0.1.0-py3-none-any.whl.
File metadata
- Download URL: gpupilot-0.1.0-py3-none-any.whl
- Upload date:
- Size: 20.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
877cbf37f9e3e12eb07b6856cafab827f3b0030f30a5a90ef2aa3d19c52fb1e1
|
|
| MD5 |
d6158121e2742c640a327c055a0359f3
|
|
| BLAKE2b-256 |
ba3448941f0fd6de88b7001aa1f42d55ea6557046bbbac5eb888aa2b0ced3025
|