Skip to main content

Inference on a GPU box

On a plain NAS the app runs the DINOv2 encoder, its eight heads and the two detectors in its own process, on the CPU, once per picture. The inference service moves that work to another machine: a GPU box, a Kubernetes node, or just a container you can restart on its own. The facts are the same rows either way, so you can add it, move it or drop it without re-deriving anything.

It is modelled on immich-machine-learning: one image per backend, weights in a cache volume, weights dropped when idle. It pays on a slow box. On a four-core Celeron NAS, sending the facts to a service on a cluster took preparation from about 1.5 s to 0.45 s a picture; on a Mac, which computes them in process in tens of milliseconds, it buys nothing. It does nothing for the render: for that, see Render on a GPU box.

It answers on port 8092, which is also where caption_base_url looks for the caption server. Two services, one default port: on one host, move one (the compose file publishes captions on 8094 for that reason).

The service also runs Demucs for music stems. ACE-Step stays in its own deployment.

What leaves the app: picture previews and generated full music tracks sent for separation. Nothing behind the port checks a credential, so keep it on your LAN.

The two images​

TagPlatformsProvider
:X.Y.Zlinux/amd64, linux/arm64CPU
:X.Y.Z-cudalinux/amd64CUDA, falling back to CPU

openvino, armnn and rocm are not shipped. Quick Sync, VAAPI and NVENC decode, scale and encode; they run no inference.

The card accelerates the DINOv2 encoder, its eight heads and both detectors. All three are ONNX graphs and all three open on the provider the deployment chose, so the -cuda image moves every producer onto the GPU. A graph the card turns down falls back to the CPU.

Both images are published by the release, so you pull rather than build:

docker pull ghcr.io/sam-dumont/immich-video-memory-generator/inference:latest
docker pull ghcr.io/sam-dumont/immich-video-memory-generator/inference:latest-cuda

From a checkout, docker/Dockerfile.inference builds either one: --build-arg DEVICE=cpu or DEVICE=cuda, with --build-arg APP_VERSION=0+local.

Music stems​

With advanced.inference.facts_base_url set, the app sends generated music to POST /audio/stems and receives a ZIP containing drums.wav, bass.wav, other.wav and vocals.wav. The endpoint accepts one multipart file, up to 64 MiB. Jobs run serially off the HTTP event loop; temporary audio is deleted after the response. Both inference images include Demucs. The CUDA image bundles its weights; the CPU image downloads them on first use into /cache/torch.

advanced.inference.fallback_to_local also controls recovery from a failed stem request. Its default is true: the app uses local Demucs if installed. An explicitly enabled MusicGen server keeps priority for stems. Neither setting changes the ACE-Step generation endpoint.

Running it with compose​

The service ships in docker-compose.yml behind a profile, so a plain up -d stays one container:

docker compose --profile inference up -d
curl -s localhost:8092/health

For a GPU, install the NVIDIA container toolkit from NVIDIA's guide and check that a container sees the card:

sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi

Then uncomment the device reservation on that service and change the tag with it:

    deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities:
- gpu
INFERENCE_TAG=latest-cuda docker compose --profile inference up -d
curl -s localhost:8092/health | grep CUDAExecutionProvider

The published file carries that block inline because it is downloaded on its own; from a checkout, docker/hwaccel.inference.yml holds both backends as extends: targets instead.

/health names the provider per producer, in producers.heads.providers, producers.nsfw_marqo.providers and producers.doc_docling.providers, each empty until that producer has loaded. CPUExecutionProvider on a GPU host means the image is the CPU one, the device reservation did not reach the container, PROVIDER names cpu, or the driver and the CUDA runtime in the image do not match. Check the tag first: it is the usual one. A graph ONNX Runtime turns down gets one WARNING naming the seat and the fallback, so the service never serves CPU answers quietly.

On Kubernetes​

deploy/kubernetes/overlays/inference is the service on its own: a Deployment, a ClusterIP Service on 8092, a 10Gi model-cache PVC and a NetworkPolicy. It does not pull in base/, so it needs no Secret and no Immich, and runs in a cluster where the app does not.

kubectl create namespace immich-memories                     # base/ creates it too
kubectl apply -k deploy/kubernetes/overlays/inference # CPU
kubectl apply -k deploy/kubernetes/overlays/inference-cuda # NVIDIA nodes

inference-cuda is the same overlay plus one patch: runtimeClassName: nvidia, one nvidia.com/gpu, the two NVIDIA_* env vars, the nvidia.com/gpu.present=true node selector, the matching toleration and the -cuda tag. Each overlay pins its own tag in an images: entry; bump both together, and check the pin against the releases page first, it trails the current release.

Port-forward and read the provider back:

kubectl -n immich-memories port-forward svc/inference 8092:8092
curl -s localhost:8092/health

Then point the app at it: http://inference:8092 in the same namespace, http://inference.immich-memories.svc.cluster.local:8092 across namespaces. The base NetworkPolicy already allows the app egress on 8092.

A cold cache volume​

The CUDA image bundles DINOv2, the context heads, Marqo, Docling, Laya ONNX and the SmolVLM2 caption model and projector. Its weights live in /opt/immich-models, outside the writable cache mount. Downloads are unnecessary at startup; the image sets HF_HUB_OFFLINE=1. The app's CLI can use the same image, with the bundled paths already configured.

The caption server is a second process using the same image and layers:

image=ghcr.io/sam-dumont/immich-video-memory-generator/inference:latest-cuda
docker run --rm --gpus all -p 127.0.0.1:8094:8092 \
-v immich-memories-model-cache:/cache \
"$image" immich-memories-captioner

Point advanced.editorial.preparation.caption_base_url at http://localhost:8094/v1 when running the app on the host. Between containers, use the caption container's hostname and port 8092. The caption runtime is pinned by image digest; older NVIDIA cards may compile kernels on their first request. Its bounded JIT cache stays on /cache across restarts. Laya runs in the app process; the /facts service serves the image classifiers.

The CPU image keeps its smaller download. A fresh PVC is empty, which is fine. Both overlays set ALLOW_MODEL_DOWNLOADS=true, and the CPU service then fetches what it is missing on first use: the pinned DINOv2 export (88 MB), the pinned Marqo export (22.5 MB) and the Docling snapshot. The ONNX exports are checked against the same SHA-256 immich-memories models fetch pins, the Docling snapshot by Hugging Face revision. Only the first /facts call after a cold start waits for it.

To fill the volume yourself, kubectl cp the two ONNX exports into /cache, or run immich-memories models fetch in a Job that mounts the same claim. With ALLOW_MODEL_DOWNLOADS=false that is the only way in, and a request for a missing file answers 503 naming the file and the path it wants it at.

The pod's root filesystem is read-only, so the overlay sets HF_HOME=/cache/huggingface and TMPDIR=/tmp. Without them the download has nowhere to put its temporary files, fails with Read-only file system (os error 30), and every /facts request answers 503 until the pod restarts. Keep both if you write your own manifests.

Reach the service from outside the cluster​

http://inference:8092 only resolves inside the cluster. For a NAS or a laptop on the LAN, overlays/inference-lan adds a second Service, type LoadBalancer, on the same pods and port. The ClusterIP Service is untouched, so no in-cluster caller starts riding an external address, and it composes with either device overlay.

kubectl apply -k deploy/kubernetes/overlays/inference-lan
kubectl -n immich-memories get service inference-lan

Put what that prints in facts_base_url. The address comes from the cluster's load-balancer controller: without one the Service sits at <pending> forever. Nothing behind port 8092 checks a credential, so do not give it a routable address, and kubectl delete -k deploy/kubernetes/overlays/inference-lan when you are done.

What it answers​

EndpointQuestion
GET /pingare you up
GET /healthwhich producers are loaded, at which versions, on which provider each
POST /factsone picture in: what do the frozen classifiers say about it
python3 -c 'import base64,json,sys; print(json.dumps({"image": base64.b64encode(open(sys.argv[1],"rb").read()).decode(), "producers": ["heads"]}))' photo.jpg \
| curl -s localhost:8092/facts -H 'content-type: application/json' --data-binary @-
{"producers": {"heads": {"encoder_key": "…", "facts": [
{"head": "activity", "version": "public-v1", "label": "outdoors", "confidence": 0.71}]}}}

When a producer cannot load, /facts answers 503 with the reason in detail: which producer, which artifact, which path. The app repeats that as Remote classifiers returned HTTP 503: <detail>, and the service logs it once per distinct message rather than once per picture. A failed load is retried on a later request, backing off from 10 s to 2 minutes, so fixing the cause needs no restart.

A fact on the wire is the bank row without its asset id, and the client stores it verbatim: a fact's identity is the artifact that produced it, never the machine that ran it. No URL, hostname, device or provider name enters any key, so the same picture through the cpu and the cuda image lands on one row, label-identical rather than byte-identical.

Settings​

Every setting is an environment variable prefixed IMMICH_MEMORIES_INFERENCE_:

VariableDefaultWhat it does
HOST / PORT127.0.0.1 / 8092where to listen. The image sets the host to 0.0.0.0
CACHE_DIR/cachethe model cache volume
ENCODER$CACHE_DIR/dinov2-small.onnxthe pinned DINOv2 export, digest-verified on load
MARQO_ONNX$CACHE_DIR/nsfw-marqo-384.onnxthe pinned sensitive-content ONNX export
BUNDLEthe packaged public bundlehead bundle .npz
PROVIDERautoauto, cpu, cuda or coreml. auto takes CUDA where the provider is present and CPU otherwise. CoreML is selectable but measured 6 to 8 times slower than the CPU provider on this export, at 9 times the resident memory
REQUEST_THREADS4the thread pool in front of ONNX Runtime. The app's facts_concurrency is what fills it
IDLE_UNLOAD_SECONDS300drop idle weights; 0 holds them
PRELOADfalseload every producer at boot instead of on first use
DETECTOR_CACHE_DIR/cache/huggingface in the published image ($HF_HOME otherwise)where the detector snapshots live
ALLOW_MODEL_DOWNLOADSfalselet a cold cache fetch the pinned exports and the Docling snapshot itself
MAX_IMAGE_BYTES16777216refuse anything larger

Idle unload drops the weights and keeps the process: the next request reloads them. Changing the provider re-keys nothing, so any of this can be retried without re-deriving a fact.

Point the app at it​

advanced:
inference:
facts_base_url: http://inference:8092

That is the whole switch (IMMICH_MEMORIES_INFERENCE__FACTS_BASE_URL in the environment, two underscores; the service's own settings take one). prepare and generate then send each picture's preview to /facts once, asking for every producer the tier still needs. The other four keys, producers, timeout_seconds, facts_concurrency and fallback_to_local, are in the config reference.

facts_concurrency (default 8, 1 to 32) decides whether the card behind the service is worth anything: one request at a time measured 0.69 s a picture whatever the card was doing, because the round trip and not the classifier was the cost. Match it to the service's REQUEST_THREADS and give the pod the CPU to go with them; past that the requests queue inside the service. Raising it re-derives nothing. What a GPU-backed service, a CPU-backed one and a pod computing its own facts each measure is on Measured.

The summary's remote_facts row carries a service s/pic column beside the wall clock, off the X-Facts-Seconds header. A wide gap between the two is the network or a queue inside the service; a narrow one means the classifiers are the cost, and only a faster device or a smaller scope moves it.