feat: add GPU/CUDA acceleration support (:cuda Docker tag)

Add a :cuda Docker image tag that auto-detects NVIDIA GPU at runtime
and falls back gracefully to CPU. Same pattern as Immich.

- New gpu.py shared utility for cached CUDA detection
- Background removal (rembg): pass CUDAExecutionProvider to ONNX Runtime
- Upscaling (Real-ESRGAN): use CUDA device + FP16 when GPU available
- OCR (PaddleOCR): enable use_gpu when CUDA detected
- Dispatcher reports GPU status at startup via readiness signal
- Admin health endpoint exposes GPU availability
- Dockerfile uses ARG GPU=false with conditional NVIDIA CUDA base image
- docker-compose.gpu.yml override for GPU users
- CI/CD workflows build and publish :cuda tag (amd64 only)

Three tags: :latest (CPU), :lite (no AI), :cuda (GPU with CPU fallback)
This commit is contained in:
Siddharth Kumar Sah
2026-04-05 19:12:45 +08:00
parent d0c69d6a46
commit 29a382e9e0
13 changed files with 182 additions and 33 deletions
+17 -6
View File
@@ -80,11 +80,20 @@ jobs:
- run: pnpm build - run: pnpm build
docker: docker:
name: Docker Build Test (${{ matrix.variant }}) name: Docker Build Test (${{ matrix.tag }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
variant: [full, lite] include:
- tag: full
variant: full
gpu: "false"
- tag: lite
variant: lite
gpu: "false"
- tag: cuda
variant: full
gpu: "true"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -95,7 +104,9 @@ jobs:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
push: false push: false
build-args: VARIANT=${{ matrix.variant }} build-args: |
tags: stirling-image:ci-${{ matrix.variant }} VARIANT=${{ matrix.variant }}
cache-from: type=gha,scope=${{ matrix.variant }} GPU=${{ matrix.gpu }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant }} tags: stirling-image:ci-${{ matrix.tag }}
cache-from: type=gha,scope=${{ matrix.tag }}
cache-to: type=gha,mode=max,scope=${{ matrix.tag }}
+21 -9
View File
@@ -49,18 +49,28 @@ jobs:
fi fi
docker: docker:
name: Docker (${{ matrix.variant }}) name: Docker (${{ matrix.tag }})
needs: release needs: release
if: needs.release.outputs.new_version != '' if: needs.release.outputs.new_version != ''
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
variant: [full, lite]
include: include:
- variant: full - tag: full
variant: full
gpu: "false"
suffix: "" suffix: ""
- variant: lite platforms: "linux/amd64,linux/arm64"
- tag: lite
variant: lite
gpu: "false"
suffix: "-lite" suffix: "-lite"
platforms: "linux/amd64,linux/arm64"
- tag: cuda
variant: full
gpu: "true"
suffix: "-cuda"
platforms: "linux/amd64"
steps: steps:
- name: Checkout release tag - name: Checkout release tag
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -97,7 +107,7 @@ jobs:
type=semver,pattern={{version}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }} type=semver,pattern={{version}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
type=semver,pattern={{major}}.{{minor}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }} type=semver,pattern={{major}}.{{minor}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
type=semver,pattern={{major}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }} type=semver,pattern={{major}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
type=raw,value=${{ matrix.variant == 'full' && 'latest' || 'lite' }} type=raw,value=${{ matrix.tag == 'full' && 'latest' || matrix.tag }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -105,9 +115,11 @@ jobs:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
push: true push: true
build-args: VARIANT=${{ matrix.variant }} build-args: |
platforms: linux/amd64,linux/arm64 VARIANT=${{ matrix.variant }}
GPU=${{ matrix.gpu }}
platforms: ${{ matrix.platforms }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.variant }} cache-from: type=gha,scope=${{ matrix.tag }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant }} cache-to: type=gha,mode=max,scope=${{ matrix.tag }}
+2 -1
View File
@@ -1,5 +1,6 @@
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
import { isGpuAvailable } from "@stirling-image/ai";
import { APP_VERSION } from "@stirling-image/shared"; import { APP_VERSION } from "@stirling-image/shared";
import Fastify from "fastify"; import Fastify from "fastify";
import { env } from "./config.js"; import { env } from "./config.js";
@@ -132,7 +133,7 @@ app.get("/api/v1/admin/health", async (request, reply) => {
storage: { mode: env.STORAGE_MODE, available: "N/A" }, storage: { mode: env.STORAGE_MODE, available: "N/A" },
database: dbOk ? "ok" : "error", database: dbOk ? "ok" : "error",
queue: { active: 0, pending: 0 }, queue: { active: 0, pending: 0 },
ai: {}, ai: { gpu: isGpuAvailable() },
}; };
}); });
+50 -11
View File
@@ -5,6 +5,7 @@
# ============================================ # ============================================
ARG VARIANT=full ARG VARIANT=full
ARG GPU=false
# ============================================ # ============================================
# Stage 1: Build the frontend (Vite + React) # Stage 1: Build the frontend (Vite + React)
@@ -38,11 +39,35 @@ RUN --mount=type=cache,id=turbo-cache,target=/app/.turbo \
pnpm --filter @stirling-image/web build pnpm --filter @stirling-image/web build
# ============================================ # ============================================
# Stage 2: Production runtime # Stage 2: Base image selection
# ============================================ # ============================================
FROM node:22-bookworm AS production FROM node:22-bookworm AS base-cpu
FROM nvidia/cuda:12.6.3-runtime-ubuntu24.04 AS base-gpu
# Select base: GPU=false -> base-cpu, GPU=true -> base-gpu
FROM base-cpu AS production-base-false
FROM base-gpu AS production-base-true
# ============================================
# Stage 3: Production runtime
# ============================================
FROM production-base-${GPU} AS production
ARG VARIANT ARG VARIANT
ARG GPU
# Install Node.js when using CUDA base (node:22-bookworm already has it)
RUN if [ "$GPU" = "true" ]; then \
apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates gnupg && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | \
gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > \
/etc/apt/sources.list.d/nodesource.list && \
apt-get update && apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/* \
; fi
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
@@ -68,17 +93,30 @@ RUN if [ "$VARIANT" = "full" ]; then \
# Python venv + ML packages + model weights (full variant only) # Python venv + ML packages + model weights (full variant only)
COPY packages/ai/python/requirements.txt /tmp/requirements.txt COPY packages/ai/python/requirements.txt /tmp/requirements.txt
COPY packages/ai/python/requirements-gpu.txt /tmp/requirements-gpu.txt
RUN if [ "$VARIANT" = "full" ]; then \ RUN if [ "$VARIANT" = "full" ]; then \
python3 -m venv /opt/venv && \ python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip && \ /opt/venv/bin/pip install --upgrade pip && \
/opt/venv/bin/pip install \ if [ "$GPU" = "true" ]; then \
Pillow numpy opencv-python-headless onnxruntime && \ /opt/venv/bin/pip install \
(/opt/venv/bin/pip install "rembg[cpu]" || echo "WARNING: rembg not installed") && \ Pillow numpy opencv-python-headless onnxruntime-gpu && \
(/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed") && \ (/opt/venv/bin/pip install rembg || echo "WARNING: rembg not installed") && \
(/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed") && \ (/opt/venv/bin/pip install realesrgan \
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \ --extra-index-url https://download.pytorch.org/whl/cu126 \
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \ || echo "WARNING: realesrgan not installed") && \
; fi && rm -f /tmp/requirements.txt (/opt/venv/bin/pip install paddlepaddle-gpu paddleocr || echo "WARNING: PaddleOCR not installed") && \
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \
; else \
/opt/venv/bin/pip install \
Pillow numpy opencv-python-headless onnxruntime && \
(/opt/venv/bin/pip install "rembg[cpu]" || echo "WARNING: rembg not installed") && \
(/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed") && \
(/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed") && \
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \
; fi \
; fi && rm -f /tmp/requirements.txt /tmp/requirements-gpu.txt
COPY docker/download_models.py /tmp/download_models.py COPY docker/download_models.py /tmp/download_models.py
RUN if [ "$VARIANT" = "full" ]; then \ RUN if [ "$VARIANT" = "full" ]; then \
@@ -152,7 +190,8 @@ ENV PORT=1349 \
CONCURRENT_JOBS=3 \ CONCURRENT_JOBS=3 \
MAX_MEGAPIXELS=100 \ MAX_MEGAPIXELS=100 \
RATE_LIMIT_PER_MIN=100 \ RATE_LIMIT_PER_MIN=100 \
STIRLING_VARIANT=${VARIANT} STIRLING_VARIANT=${VARIANT} \
STIRLING_GPU=${GPU}
# Create non-root user for runtime # Create non-root user for runtime
RUN groupadd -r stirling && useradd -r -g stirling -d /app -s /sbin/nologin stirling RUN groupadd -r stirling && useradd -r -g stirling -d /app -s /sbin/nologin stirling
+17
View File
@@ -0,0 +1,17 @@
# GPU override - use with:
# docker compose -f docker/docker-compose.yml -f docker/docker-compose.gpu.yml up
services:
stirling-image:
build:
context: ..
dockerfile: docker/Dockerfile
args:
GPU: "true"
image: stirlingimage/stirling-image:cuda
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
+9 -2
View File
@@ -39,6 +39,7 @@ def _try_import(name, import_fn):
_try_import("PIL", lambda: __import__("PIL")) _try_import("PIL", lambda: __import__("PIL"))
_try_import("cv2", lambda: __import__("cv2")) _try_import("cv2", lambda: __import__("cv2"))
_try_import("numpy", lambda: __import__("numpy")) _try_import("numpy", lambda: __import__("numpy"))
_try_import("gpu", lambda: __import__("gpu"))
# Heavy ML libraries - import but don't fail if unavailable # Heavy ML libraries - import but don't fail if unavailable
_try_import("rembg", lambda: __import__("rembg")) _try_import("rembg", lambda: __import__("rembg"))
@@ -123,8 +124,14 @@ def _run_script_main(script_name, args):
def main(): def main():
# Signal readiness # Signal readiness with GPU status
print(json.dumps({"ready": True}), file=sys.stderr, flush=True) gpu = False
try:
from gpu import gpu_available
gpu = gpu_available()
except ImportError:
pass
print(json.dumps({"ready": True, "gpu": gpu}), file=sys.stderr, flush=True)
for line in sys.stdin: for line in sys.stdin:
line = line.strip() line = line.strip()
+34
View File
@@ -0,0 +1,34 @@
"""Runtime GPU/CUDA detection utility."""
import functools
import os
@functools.lru_cache(maxsize=1)
def gpu_available():
"""Return True if a usable CUDA GPU is present at runtime."""
override = os.environ.get("STIRLING_GPU")
if override is not None:
return override.lower() in ("1", "true", "yes")
try:
import onnxruntime
if "CUDAExecutionProvider" in onnxruntime.get_available_providers():
return True
except ImportError:
pass
try:
import torch
if torch.cuda.is_available():
return True
except ImportError:
pass
return False
def onnx_providers():
"""Return ONNX Runtime execution providers in priority order."""
if gpu_available():
return ["CUDAExecutionProvider", "CPUExecutionProvider"]
return ["CPUExecutionProvider"]
+2 -1
View File
@@ -34,9 +34,10 @@ def run_paddleocr(input_path, language):
"""Run PaddleOCR.""" """Run PaddleOCR."""
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
from paddleocr import PaddleOCR from paddleocr import PaddleOCR
from gpu import gpu_available
emit_progress(20, "Loading") emit_progress(20, "Loading")
ocr = PaddleOCR(lang=language) ocr = PaddleOCR(lang=language, use_gpu=gpu_available())
emit_progress(30, "Scanning") emit_progress(30, "Scanning")
result = ocr.ocr(input_path) result = ocr.ocr(input_path)
emit_progress(70, "Extracting text") emit_progress(70, "Extracting text")
+2 -1
View File
@@ -24,11 +24,12 @@ def main():
try: try:
from rembg import remove, new_session from rembg import remove, new_session
from gpu import onnx_providers
import io import io
emit_progress(10, "Loading model") emit_progress(10, "Loading model")
session = new_session(model) session = new_session(model, providers=onnx_providers())
emit_progress(25, "Model loaded") emit_progress(25, "Model loaded")
+10
View File
@@ -0,0 +1,10 @@
rembg==2.0.62
realesrgan==0.3.0
lama-cleaner==1.2.5
paddleocr==2.9.1
paddlepaddle-gpu==3.0.0
mediapipe==0.10.21
onnxruntime-gpu==1.20.1
numpy==1.26.4
Pillow==11.1.0
opencv-python-headless==4.10.0.84
+7 -1
View File
@@ -26,7 +26,12 @@ def main():
try: try:
from basicsr.archs.rrdbnet_arch import RRDBNet from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer from realesrgan import RealESRGANer
from gpu import gpu_available
import numpy as np import numpy as np
import torch
use_gpu = gpu_available()
device = torch.device("cuda" if use_gpu else "cpu")
model = RRDBNet( model = RRDBNet(
num_in_ch=3, num_in_ch=3,
@@ -40,7 +45,8 @@ def main():
scale=scale, scale=scale,
model_path=None, model_path=None,
model=model, model=model,
half=False, half=use_gpu,
device=device,
) )
emit_progress(20, "Model ready") emit_progress(20, "Model ready")
img_array = np.array(img.convert("RGB")) img_array = np.array(img.convert("RGB"))
+10
View File
@@ -54,6 +54,8 @@ interface PendingRequest {
let dispatcher: ChildProcess | null = null; let dispatcher: ChildProcess | null = null;
let dispatcherReady = false; let dispatcherReady = false;
let dispatcherFailed = false; let dispatcherFailed = false;
// biome-ignore lint/style/useConst: reassigned on dispatcher readiness signal
let dispatcherGpuAvailable = false;
const pendingRequests = new Map<string, PendingRequest>(); const pendingRequests = new Map<string, PendingRequest>();
let stdoutBuffer = ""; let stdoutBuffer = "";
@@ -82,6 +84,7 @@ function startDispatcher(): ChildProcess | null {
// Readiness signal // Readiness signal
if (parsed.ready === true) { if (parsed.ready === true) {
dispatcherReady = true; dispatcherReady = true;
dispatcherGpuAvailable = parsed.gpu === true;
continue; continue;
} }
@@ -221,6 +224,13 @@ function dispatcherRun(
}); });
} }
/**
* Whether the Python dispatcher detected a CUDA GPU at startup.
*/
export function isGpuAvailable(): boolean {
return dispatcherGpuAvailable;
}
/** /**
* Shut down the persistent dispatcher process. * Shut down the persistent dispatcher process.
*/ */
+1 -1
View File
@@ -1,5 +1,5 @@
export { removeBackground } from "./background-removal.js"; export { removeBackground } from "./background-removal.js";
export { shutdownDispatcher } from "./bridge.js"; export { isGpuAvailable, shutdownDispatcher } from "./bridge.js";
export { blurFaces } from "./face-detection.js"; export { blurFaces } from "./face-detection.js";
export { inpaint } from "./inpainting.js"; export { inpaint } from "./inpainting.js";
export { extractText } from "./ocr.js"; export { extractText } from "./ocr.js";