') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - KatherLab/LLM-Scheduler: Scheduling vLLM instances on multiple GPUs using SLURM · GitHub
Skip to content

Repository files navigation

License

KatherLab LLM Scheduler

A web-based tool for scheduling and serving large language models (LLMs) on shared GPU servers. Built for research teams, labs, and organizations that need to coordinate access to limited GPU resources across multiple models and users.

UI Screenshot


What problem does this solve?

If your lab or team has a powerful GPU server (e.g., an 8×H200 HGX node) and multiple people want to run different LLMs at different times, things get messy fast:

  • Who's using which GPUs right now?
  • Can I run my model without conflicting with someone else's?
  • How do I start/stop vLLM without SSH-ing into the server every time?
  • How do my scripts and tools connect to the right model endpoint?

KatherLab LLM Scheduler solves all of this. Think of it as a shared calendar for your GPUs — with automatic model lifecycle management and a built-in OpenAI-compatible API proxy.


Key Features

  • 📅 Visual GPU timeline — see at a glance what's running, what's planned, and where there's free capacity. Drag-and-drop to create, move, and resize bookings.
  • 🚀 One-click model start — pick from a pre-configured model catalog, choose a time and duration, and the scheduler handles the rest (Slurm job submission, health checks, routing).
  • ASAP booking — automatically finds the earliest free GPU slot for your model.
  • 🔀 OpenAI-compatible API proxy — your apps (Open WebUI, LiteLLM, Python scripts, etc.) always connect to one stable address. The scheduler routes requests to the correct running model behind the scenes.
  • 🔁 Automatic retries — if a model fails to start (e.g., OOM), the scheduler retries automatically.
  • 📋 Live Slurm logs — view stdout/stderr directly from the web UI. No SSH needed.
  • ⏱️ Extend, shorten, or stop running models from the UI — the scheduler updates the Slurm job time limit accordingly.
  • 🔒 Password-protected access — simple authentication to keep the UI secure.
  • 🏷️ Model tags — filter models by capability (e.g., vision, reasoning).
  • 🌙 Dark mode

How it works

 You (browser) KatherLab LLM Scheduler GPU Server (Slurm)
┌──────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Web UI │───────────▶│ Scheduler + Router │───────────▶│ vLLM instances │
│ │◀───────────│ (FastAPI app) │◀───────────│ (Slurm jobs) │
└──────────┘ └──────────────────────┘ └──────────────────┘
  1. You open the web UI and see a timeline of GPU usage and a catalog of available models.
  2. You create a booking — e.g., "Run Qwen3.5-397B from 10:00 to 18:00 on 4 GPUs."
  3. The scheduler submits a Slurm job that starts vLLM with the right model, GPU allocation, and configuration.
  4. Once the model is healthy, the scheduler marks it as ready and begins routing API requests to it.
  5. When the booking ends, the Slurm job is cancelled and the GPUs are freed for the next booking.

Prerequisites

Before setting up the scheduler, make sure you have the following:

1. A Linux server with Slurm

The scheduler uses Slurm to manage GPU jobs. You need:

  • A working Slurm installation with sbatch, squeue, scancel, and scontrol available.
  • sacct and slurmdbd must be configured and running. The scheduler uses sacct to determine why jobs ended (OOM, timeout, normal completion, etc.) and to decide whether to retry failed jobs. Without sacct/slurmdbd, the retry and reconciliation logic will not work correctly.
  • GPU resources configured in Slurm (e.g., --gres=gpu:N).

2. GPUs

At least one GPU. The scheduler is designed for multi-GPU servers (e.g., 4×A100, 8×H100) where different models need different numbers of GPUs.

3. Python 3.13+

The scheduler itself requires Python 3.13 or newer.

4. uv (fast Python package manager)

Used to manage the scheduler's own Python environment. Install it with:

curl -LsSf https://astral.sh/uv/install.sh | sh

5. A vLLM installation (one or more venvs)

Each model in your catalog points to a Python virtual environment that has vLLM installed. The scheduler launches vLLM via Slurm jobs using the venv_activate path specified in the model config.

You can have multiple vLLM venvs — for example, different vLLM versions for different models, or a nightly build for models that require bleeding-edge features. Each model entry in config/models.yaml specifies which venv to use.


Setting up vLLM

The scheduler does not install vLLM for you — it just launches it. You need to prepare one or more virtual environments with vLLM installed.

Quick setup with uv

# Create a new venv (e.g., for vLLM stable)cd /opt/vllm-envs
uv venv --python 3.12 --seed --managed-python
source .venv/bin/activate
# Install vLLM with automatic PyTorch backend detection
uv pip install vllm --torch-backend=auto

Note: Some models require specific vLLM versions or extra dependencies. Always check:

  • The model's Hugging Face page for recommended vLLM versions and launch arguments.
  • The vLLM installation guide for detailed GPU-specific instructions (CUDA versions, flash-attention, etc.).
  • The vLLM recipes for model-specific configurations and optimizations.

Example: multiple venvs for different models

# Stable vLLM for most models
mkdir -p /opt/vllm-stable &&cd /opt/vllm-stable
uv venv --python 3.12 --seed --managed-python
source .venv/bin/activate
uv pip install vllm --torch-backend=auto
# Nightly vLLM for models requiring latest features
mkdir -p /opt/vllm-nightly &&cd /opt/vllm-nightly
uv venv --python 3.12 --seed --managed-python
source .venv/bin/activate
uv pip install -U vllm --torch-backend=auto --extra-index-url https://wheels.vllm.ai/nightly

Then reference the appropriate venv in each model's config:

models:
- name: My-Stable-Modelvenv_activate: /opt/vllm-stable/.venv/bin/activate# ...
- name: My-Bleeding-Edge-Modelvenv_activate: /opt/vllm-nightly/.venv/bin/activate# ...

Quick Start

1. Clone the repository

git clone https://github.com/KatherLab/LLM-Scheduler.git
cd LLM-Scheduler

2. Install scheduler dependencies

uv sync

This creates a virtual environment and installs all dependencies for the scheduler itself (FastAPI, SQLAlchemy, etc.).

3. Configure environment

cp config/example.env .env

Edit .env and adjust the following settings:

SettingWhat it doesExample
AUTH_PASSWORDPassword to log into the web UImy-secret-password
PUBLIC_HOSTNAMEHostname or IP that users and vLLM jobs use to reach the schedulergpu-server.mylab.org
ROUTER_PORTPort the scheduler listens on9000
TOTAL_GPUSNumber of GPUs available on the server8
VLLM_API_KEYAPI Key for using the /v1 endpointssome-random-string
SLURM_PARTITIONSlurm partition to submit jobs to (leave empty for default)gpu
DATABASE_URLPath to the SQLite database filesqlite:///./router.db
VLLM_LOG_DIRDirectory where Slurm job logs are stored./logs
SBATCH_TEMPLATE_PATHPath to the Slurm job script template./templates/vllm_job.sh
VLLM_HEALTH_TIMEOUT_SECONDSHow long to wait for a model to become healthy before marking it failed800
VLLM_MAX_RETRIESNumber of times to retry a failed model launch1

4. Configure your model catalog

cp config/models.example.yaml config/models.yaml

Edit config/models.yaml to list the models you want to make available. Each entry specifies the model path, GPU requirements, vLLM arguments, and which venv to use:

models:
- name: Qwen3-0.6B-FP8 # Display name (also used as --served-model-name)model_path: Qwen/Qwen3-0.6B-FP8 # HF model ID or local pathgpus: 1# Number of GPUs requiredtensor_parallel_size: 1# vLLM tensor parallelismcpus: 4# CPUs to request from Slurmmem: "16G"# Memory to request from Slurmgpu_memory_utilization: 0.1# vLLM --gpu-memory-utilizationreasoning_parser: deepseek_r1 # vLLM --reasoning-parser (optional)extra_args: "--max-num-seqs 10 --enforce-eager --max-model-len 2048"venv_activate: /opt/vllm-stable/.venv/bin/activatetags: [reasoning] # Tags for UI filtering (optional)notes: "Small test model"# Description shown in UI (optional)env: # Extra environment variables (optional)VLLM_SOME_FLAG: "1"

Key fields:

FieldRequiredDescription
nameUnique model name. Used in the UI and as the model parameter in API calls.
model_pathHugging Face model ID (e.g., Qwen/Qwen3-0.6B-FP8) or absolute path to a local model directory.
gpusNumber of GPUs the model needs.
tensor_parallel_sizevLLM tensor parallel size (usually equals gpus).
venv_activatePath to the activate script of the venv with vLLM installed.
cpusRecommendedNumber of CPUs to request from Slurm.
memRecommendedMemory to request from Slurm (e.g., "64G").
extra_argsOptionalAdditional vLLM CLI arguments.
tool_argsOptionalTool-calling arguments (e.g., --enable-auto-tool-choice --tool-call-parser hermes).
reasoning_parserOptionalvLLM reasoning parser (e.g., deepseek_r1, qwen3).
gpu_memory_utilizationOptionalGPU memory fraction (default: 0.95).
tagsOptionalList of tags for filtering in the UI (e.g., [vision, reasoning]).
notesOptionalShort description shown in the catalog UI.
envOptionalExtra environment variables passed to the vLLM process.

Shared defaults:

An optional top-level defaults: block holds settings that apply to every model, so cluster-wide flags and paths only have to be written once:

defaults:
extra_args: "--enable-prompt-tokens-details --max-num-batched-tokens 16384"venv_activate: /opt/vllm-stable/.venv/bin/activatecpus: 8env:
HF_HUB_OFFLINE: "1"models:
- name: Qwen3-0.6B-FP8model_path: Qwen/Qwen3-0.6B-FP8gpus: 1tensor_parallel_size: 1extra_args: "--enforce-eager --max-num-batched-tokens 4096"# effective: --enable-prompt-tokens-details --enforce-eager --max-num-batched-tokens 4096

Only put flags here that make sense for every model. Per-model concerns — --max-model-len in particular, where the model's own default context length is usually the right choice — belong on the model entry.

It accepts the same fields as a model entry, and the model always wins:

  • Scalars (venv_activate, cpus, mem, gpu_memory_utilization, reasoning_parser, …) — the model's value replaces the default.
  • env — merged key by key; the model's keys win.
  • extra_args / tool_args — the defaults are prepended, but a default flag is dropped when the model sets the same flag, so it gets overridden rather than passed twice (--flag value and --flag=value count as the same flag).
  • name, model_path, notes, tags — always taken from the model entry.

Defaults are resolved when the catalog is (re)loaded, so they take effect for bookings made after the change; already-submitted bookings keep the arguments they were created with.

5. Run the scheduler

uv run uvicorn app.main:app --host 0.0.0.0 --port 9000

Then open http://your-server:9000 in your browser and log in with your configured password.


Usage

Creating a booking via the web UI

  1. Open the scheduler in your browser.
  2. Browse the Model Catalog on the left — you can search, filter by status, or filter by tags.
  3. Click Schedule on a model, or drag it onto the GPU timeline.
  4. Choose a start time (Now, ASAP, a specific time) and duration.
  5. Click Create Booking.

The scheduler will submit a Slurm job, wait for the model to become healthy, and then start routing requests to it.

Connecting your apps

Once a model is running, send requests to the scheduler's address — it acts as an OpenAI-compatible proxy:

curl http://your-server:9000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{ "model": "Qwen3-0.6B-FP8", "messages": [{"role": "user", "content": "Hello!"}] }'

This works with any OpenAI-compatible client, e.g.:

  • Python openai library: set base_url="http://your-server:9000/v1"

The scheduler supports the following proxy endpoints:

EndpointDescription
POST /v1/chat/completionsChat completions (streaming and non-streaming)
POST /v1/responsesResponses API
POST /v1/messagesMessages API
POST /v1/audio/transcriptionsAudio transcription (multipart)
POST /v1/audio/translationsAudio translation (multipart)
GET /v1/modelsList available models and their status

Managing running models

From the web UI, you can:

  • Extend a running model's booking (+1h, +2h, +4h, or drag the right edge).
  • Shorten a booking to free GPUs earlier.
  • Stop a model immediately (cancels the Slurm job).
  • View logs (stdout/stderr) directly in the browser.
  • Edit notes on bookings to communicate with your team.

Project Structure

├── app/ # Python backend (FastAPI)
│ ├── main.py # App entry point, background workers (health, retry, cleanup, reconcile)
│ ├── admin.py # Booking/lease management API
│ ├── slurm.py # Slurm integration (sbatch, scancel, scontrol, squeue, sacct)
│ ├── planner.py # GPU allocation algorithm (lane packing, conflict detection, ASAP search)
│ ├── proxy.py # OpenAI-compatible request proxy with streaming support
│ ├── router_core.py # Endpoint selection and health checking
│ ├── catalog.py # Model catalog loader (auto-reloads on file change)
│ ├── models.py # SQLAlchemy ORM models (Lease, Endpoint)
│ ├── schemas.py # Pydantic request/response schemas
│ ├── settings.py # Configuration (from .env)
│ ├── auth.py # Session-based authentication
│ ├── db.py # Database engine setup (SQLite with WAL mode)
│ ├── dependencies.py # Shared DB session factory
│ ├── lifecycle_logger.py # Structured lifecycle event logging
│ ├── utils.py # Timezone utilities
│ └── ui/ # Web frontend (HTML + JS + Tailwind CSS)
│ ├── index.html # Main scheduler UI
│ ├── login.html # Login page
│ └── app.js # Frontend application logic
├── config/
│ ├── models.yaml # Your model catalog (create from example)
│ ├── models.example.yaml # Example model catalog
│ └── example.env # Environment variable template
├── templates/
│ └── vllm_job.sh # Slurm job script template
├── pyproject.toml # Python project metadata and dependencies
└── README.md # This file

Background Workers

The scheduler runs several background workers that manage the full model lifecycle automatically:

WorkerWhat it does
Health workerPolls vLLM /health endpoints. Detects when models become ready (STARTING → READY) or crash (READY → FAILED).
Planned submit workerSubmits Slurm jobs for bookings scheduled in the future, with a configurable lead time.
Endpoint cleanup workerCancels Slurm jobs for expired or cancelled bookings.
Slurm reconcile workerCross-references DB state with Slurm reality (squeue + sacct). Detects OOM, crashes, and preemptions.
Retry workerAutomatically resubmits failed jobs (up to VLLM_MAX_RETRIES times).

Troubleshooting

"sacct: command not found" or sacct returns no data

The scheduler requires sacct (Slurm accounting) to determine why jobs ended. Make sure:

  • slurmdbd is running and configured.
  • AccountingStorageType is set in your slurm.conf (e.g., AccountingStorageType=accounting_storage/slurmdbd).
  • Test with: sacct -j <some_job_id> --format=JobID,State,ExitCode --noheader --parsable2

Model stuck in "STARTING" state

  • Check the Slurm logs via the web UI (click on the booking → "View Logs").
  • Common causes: model too large for available GPU memory, missing dependencies in the vLLM venv, incorrect model_path.
  • The model will be marked as FAILED after VLLM_HEALTH_TIMEOUT_SECONDS (default: 800s).

GPU conflict errors

The scheduler prevents overbooking GPUs. If you see a conflict error, check the timeline for overlapping bookings. You can cancel or shorten existing bookings to free up GPUs.

Models catalog not updating

The scheduler auto-reloads config/models.yaml when the file changes. If changes aren't reflected, try refreshing the browser or restarting the scheduler.


Contributing

Contributions are welcome! Please open an issue or pull request on GitHub.


License

MIT — see LICENSE for details.

About

Scheduling vLLM instances on multiple GPUs using SLURM

Resources

Stars

5 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages