aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-06 18:34:31 +0200
committerDanilo M. <danix@danix.xyz>2026-07-06 18:34:31 +0200
commitfc78b51e29497703a65f125e844f7da558dbc577 (patch)
tree5e5e3eb3c79b823971d4577d86684d154d127341
downloadimggen-master.tar.gz
imggen-master.zip
Initial commit: local SDXL image generation on Intel Arc B580HEADmaster
Containerized SDXL-base generation targeting a 12GB Arc B580 under Slackware. The container carries the Intel Level Zero runtime the host distro does not package; the fp16-fix VAE removes the fp32 upcast that would otherwise OOM at decode. - imggen: host wrapper (GPU passthrough, /out bind-mount, model cache) - generate.py: in-container XPU pipeline, scope-bounded to SDXL base - Dockerfile: pinned to the known-good torch-XPU / diffusers stack - README.md, CLAUDE.md: rationale, limits, and version-pinning notes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md48
-rw-r--r--Dockerfile54
-rw-r--r--README.md100
-rw-r--r--generate.py128
-rw-r--r--imggen38
5 files changed, 368 insertions, 0 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3c19091
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,48 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+Local SDXL-base image generation on an Intel Arc B580 (12GB) under Slackware. Three files:
+`imggen` (host bash wrapper) → `docker run` → `generate.py` (in-container, XPU). README.md carries
+the full rationale and VRAM measurements; read it before changing behavior.
+
+## Build and run
+
+```
+docker build -t imggen:local . # image tag is hardcoded as imggen:local in the wrapper
+cp imggen ~/bin/imggen && chmod +x ~/bin/imggen
+imggen "a prompt" --steps 30 --seed 42 -o out.png -n "blurry"
+```
+
+No test suite, no lint config. Verification is running `imggen` end to end and checking the PNG.
+
+## Architecture constraints (the whole point of the project)
+
+- **12GB VRAM is the hard limit.** Stock SDXL fp16 OOMs at VAE decode because diffusers upcasts
+ the VAE to fp32. The fix is the fp16-fix VAE (`madebyollin/sdxl-vae-fp16-fix`, hardcoded in
+ `generate.py`). This one swap is why the pipeline fits. Do not remove it. Peak VRAM in the
+ working config is 9.07 GB.
+- **Scope is deliberately bounded:** SDXL base, single 1024x1024 image, 25 steps, no refiner,
+ no Flux, no batching. Refiner/Flux/heavy work is explicitly out of scope and belongs on a
+ RunPod endpoint, not here. Don't add them "to be helpful."
+- **XPU only.** `build_pipe()` fails loud (`sys.exit(2)`) if `torch.xpu` is unavailable rather
+ than falling back to CPU (which would be minutes per image). Keep that behavior.
+
+## Version pinning is load-bearing
+
+The Dockerfile pins EXACT versions (torch 2.12.1+xpu, diffusers 0.39.0, transformers 5.13.0,
+etc.) captured from the known-good container. XPU-for-diffusion is young and fast-moving. Never
+`pip install -U` or loosen a pin casually. torch comes from the Intel XPU wheel index
+(`download.pytorch.org/whl/xpu`); everything else from PyPI. If you rebuild with newer versions,
+keep the old Dockerfile in git to revert.
+
+## Container boundaries
+
+- `imggen` bind-mounts host `$IMGGEN_OUT` (default `~/imggen-out`) → `/out`, and
+ `$IMGGEN_CACHE` (default `~/.cache/imggen-models`) → `/models`. `HF_HOME=/models` so the
+ ~7.5GB weights download once. Output paths in `generate.py` are all under `/out`.
+- All wrapper args pass straight through to `generate.py` argparse. Adding a CLI flag means
+ editing `generate.py` only; the wrapper needs no change.
+- GPU passthrough is `--device /dev/dri`.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..222f7d2
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,54 @@
+# Copyright (C) 2026 Danilo
+# Licensed under the GNU General Public License version 2.
+#
+# Local SDXL image generation on Intel Arc B580 under Slackware, via a
+# container that carries the Intel Level Zero runtime the host distro lacks.
+#
+# Base image: intel/oneapi-basekit ships the Level Zero loader + compute
+# runtime that make torch.xpu see the GPU. This is the piece Slackware does
+# not package, and the reason the whole approach is containerized rather
+# than native.
+#
+# IMPORTANT - version pinning:
+# The tags below are placeholders you MUST replace with the EXACT versions
+# that worked in your test session. XPU-for-diffusion is a young, fast-moving
+# stack (IPEX was retired in 2026); an unpinned rebuild can silently break a
+# working setup. Capture your real versions with:
+# python3 -m pip freeze | grep -Ei 'torch|diffusers|transformers|accelerate|huggingface|numpy|pillow|safetensors'
+# then paste them into the pip install line below, replacing the loose names.
+
+# Confirmed from the working container: oneAPI 2025.3, Ubuntu 24.04.4 Noble.
+# If this exact tag is not on Docker Hub, pick the closest oneapi-basekit tag
+# whose oneAPI version is 2025.3.x and record what you used. Do NOT use :latest.
+FROM intel/oneapi-basekit:2025.3.2-0-devel-ubuntu24.04
+
+# --- system python + pip (base image ships python3 but not pip) ---
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends python3-pip python3-venv \
+ && rm -rf /var/lib/apt/lists/*
+
+# --- python deps (PINNED to the confirmed working environment) ---
+# torch from the Intel XPU wheel index; everything else from PyPI.
+# These are the exact versions that produced ~6.3s warm SDXL-base images
+# with peak VRAM 9.07 GB on an Arc B580. Do not bump casually.
+RUN python3 -m pip install --break-system-packages \
+ --index-url https://download.pytorch.org/whl/xpu \
+ torch==2.12.1+xpu \
+ && python3 -m pip install --break-system-packages \
+ diffusers==0.39.0 \
+ transformers==5.13.0 \
+ accelerate==1.14.0 \
+ safetensors==0.8.0 \
+ huggingface_hub==1.22.0 \
+ numpy==2.5.1 \
+ pillow==12.3.0
+
+# --- app ---
+WORKDIR /app
+COPY generate.py /app/generate.py
+
+# Model cache lives here; mount a host volume at this path to avoid
+# re-downloading ~7.5GB of weights on every container start.
+ENV HF_HOME=/models
+
+ENTRYPOINT ["python3", "/app/generate.py"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d52dbeb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,100 @@
+# imggen
+
+Local SDXL image generation on an Intel Arc B580 (12GB) under Slackware, via a
+container that carries the Intel Level Zero runtime the host distro does not
+package. Prompt in, PNG on the host out.
+
+## Why this exists (and its limits)
+
+The Arc B580 runs SDXL's *compute* fast (~31 UNet it/s warm), but its *memory*
+is tight. Stock SDXL fp16 OOMs at VAE decode on 12GB because diffusers upcasts
+the VAE to fp32. Swapping in the fp16-fix VAE removes that upcast and the whole
+pipeline fits, at roughly 6.3s per warm 1024x1024 image, 25 steps.
+
+Measured peak VRAM for the working config is 9.07 GB on a 12GB card, so there
+is roughly 2.5-3 GB of real headroom, not zero.
+
+Known to work:
+
+- SDXL base, single image, 1024x1024, ~25 steps. ~6.3s warm.
+
+Plausibly fits in the remaining headroom (UNTESTED, try before assuming):
+
+- A second batched image, or modest resolution bumps above 1024.
+- Light img2img.
+
+Probably does NOT fit (a second resident model exceeds ~3GB):
+
+- SDXL refiner co-resident with base.
+
+Does NOT fit:
+
+- Flux (any variant). Far heavier than the available margin.
+
+If you want the refiner or Flux, that is the signal to move that tier of work
+to a RunPod serverless endpoint rather than fighting 12GB. Local covers the
+light case comfortably and may stretch a little past it; it does not reach the
+heavy tier.
+
+## Build
+
+The Dockerfile is PINNED to the exact versions captured from the known-good
+test container (torch 2.12.1+xpu, diffusers 0.39.0, transformers 5.13.0, etc.,
+on oneAPI 2025.3.2 / Ubuntu 24.04.4). These produced ~6.3s warm SDXL-base
+images with 9.07 GB peak VRAM on an Arc B580.
+
+Build:
+
+```
+docker build -t imggen:local .
+```
+
+If the base image tag `intel/oneapi-basekit:2025.3.2-0-devel-ubuntu24.04` is
+not available on Docker Hub, substitute the closest 2025.3.x tag and record
+what you used. Do not fall back to `:latest`.
+
+## Install the wrapper
+
+```
+cp imggen ~/bin/imggen # or anywhere on your PATH
+chmod +x ~/bin/imggen
+```
+
+## Usage
+
+```
+imggen "a red bicycle against a stone wall"
+imggen "a foggy harbour at dawn" --steps 30 --seed 42
+imggen "portrait of a fox" -o fox.png -n "blurry, low quality"
+```
+
+Output lands in `~/imggen-out` by default. Models cache in
+`~/.cache/imggen-models` so the ~7.5GB download happens once.
+
+Override paths with env vars:
+
+```
+IMGGEN_OUT=/srv/blog/img IMGGEN_CACHE=/mnt/models imggen "a lighthouse"
+```
+
+## First run
+
+The first invocation downloads SDXL base + the fp16-fix VAE (~7.5GB) into the
+cache volume. That run is network-bound and slow. Subsequent runs skip the
+download. The first *generation* in any fresh container is also slower (~7.3s)
+due to one-time kernel compilation; warm runs settle to ~6.3s.
+
+## Upgrading
+
+Treat upgrades as deliberate, tested events. Do not `pip install -U` casually.
+This working state depends on a specific torch-XPU build, a specific diffusers
+version, and the fp16-fix VAE. If you rebuild with newer versions, keep the old
+pinned Dockerfile in git so you can revert when something breaks.
+
+## Trial
+
+This is a time-boxed experiment. Run it against real blog work for a few weeks.
+If it sticks, keep it. If it does not, the fallback is a RunPod serverless
+ComfyUI endpoint wrapped in the same `imggen` CLI shape, which trades local
+privacy for the ability to run heavier models (Flux, refiner, batching)
+on-demand at zero idle cost.
diff --git a/generate.py b/generate.py
new file mode 100644
index 0000000..ba437ae
--- /dev/null
+++ b/generate.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+# Copyright (C) 2026 Danilo
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# Local SDXL-base image generation for Intel Arc B580 (XPU backend).
+# Bounded scope by design: SDXL base, single 1024x1024 image, no refiner,
+# no Flux, no batching. This is what fits comfortably in 12GB VRAM once the
+# fp32 VAE upcast is removed via the fp16-fix VAE. See README.md for limits.
+
+import argparse
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+import torch
+from diffusers import StableDiffusionXLPipeline, AutoencoderKL
+
+MODEL = "stabilityai/stable-diffusion-xl-base-1.0"
+# fp16-fix VAE avoids the fp32 upcast that OOMs a 12GB card at decode time.
+VAE = "madebyollin/sdxl-vae-fp16-fix"
+OUT_DIR = Path("/out")
+
+
+def build_pipe():
+ if not torch.xpu.is_available():
+ # Fail loud and specific. A silent CPU fallback would run but take
+ # minutes per image, which is worse than a clear error.
+ print(
+ "ERROR: torch.xpu is not available. The Level Zero runtime is not "
+ "visible inside this container. Check that you passed --device "
+ "/dev/dri and that the base image ships the Intel GPU runtime.",
+ file=sys.stderr,
+ )
+ sys.exit(2)
+
+ vae = AutoencoderKL.from_pretrained(VAE, torch_dtype=torch.float16)
+ pipe = StableDiffusionXLPipeline.from_pretrained(
+ MODEL,
+ vae=vae,
+ torch_dtype=torch.float16,
+ use_safetensors=True,
+ variant="fp16",
+ ).to("xpu")
+ return pipe
+
+
+def generate(pipe, prompt, negative, steps, guidance, seed):
+ generator = None
+ if seed is not None:
+ generator = torch.Generator(device="xpu").manual_seed(seed)
+
+ t = time.time()
+ image = pipe(
+ prompt,
+ negative_prompt=negative,
+ num_inference_steps=steps,
+ guidance_scale=guidance,
+ generator=generator,
+ ).images[0]
+ torch.xpu.synchronize()
+ elapsed = time.time() - t
+ return image, elapsed
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description="Generate a single SDXL-base image locally on Intel Arc (XPU)."
+ )
+ ap.add_argument("prompt", help="Text prompt for the image.")
+ ap.add_argument(
+ "-n", "--negative", default="", help="Negative prompt (default: none)."
+ )
+ ap.add_argument(
+ "-s", "--steps", type=int, default=25, help="Inference steps (default: 25)."
+ )
+ ap.add_argument(
+ "-g",
+ "--guidance",
+ type=float,
+ default=7.0,
+ help="Guidance scale / CFG (default: 7.0).",
+ )
+ ap.add_argument(
+ "--seed",
+ type=int,
+ default=None,
+ help="Seed for reproducible output (default: random).",
+ )
+ ap.add_argument(
+ "-o",
+ "--out",
+ default=None,
+ help="Output filename inside /out (default: timestamped).",
+ )
+ args = ap.parse_args()
+
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ if args.out:
+ out_path = OUT_DIR / args.out
+ else:
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ out_path = OUT_DIR / f"sdxl-{stamp}.png"
+
+ print(f"Loading SDXL base + fp16-fix VAE on XPU ...", file=sys.stderr)
+ t0 = time.time()
+ pipe = build_pipe()
+ print(f"Loaded in {time.time() - t0:.1f}s", file=sys.stderr)
+
+ image, elapsed = generate(
+ pipe, args.prompt, args.negative, args.steps, args.guidance, args.seed
+ )
+ image.save(out_path)
+ print(f"Generated in {elapsed:.2f}s")
+ print(f"Saved {out_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/imggen b/imggen
new file mode 100644
index 0000000..374c326
--- /dev/null
+++ b/imggen
@@ -0,0 +1,38 @@
+#!/bin/bash
+# Copyright (C) 2026 Danilo
+# Licensed under the GNU General Public License version 2.
+#
+# Host-side wrapper for local SDXL image generation on Intel Arc.
+# Handles GPU passthrough, the /out bind-mount (so output lands on the host,
+# never trapped inside the container), and a persistent model cache (so the
+# ~7.5GB of weights download once, not every run).
+#
+# Usage:
+# imggen "a red bicycle against a stone wall"
+# imggen "a foggy harbour at dawn" --steps 30 --seed 42
+# imggen "portrait of a fox" -o fox.png -n "blurry, low quality"
+#
+# All arguments are passed straight through to generate.py.
+
+set -euo pipefail
+
+IMAGE="imggen:local"
+
+# Where generated images land on the host. Override with IMGGEN_OUT.
+OUT_DIR="${IMGGEN_OUT:-$HOME/imggen-out}"
+
+# Persistent model cache on the host. Override with IMGGEN_CACHE.
+CACHE_DIR="${IMGGEN_CACHE:-$HOME/.cache/imggen-models}"
+
+mkdir -p "$OUT_DIR" "$CACHE_DIR"
+
+if [ "$#" -eq 0 ]; then
+ echo "usage: imggen \"<prompt>\" [--steps N] [--guidance G] [--seed S] [-n \"<negative>\"] [-o name.png]" >&2
+ exit 1
+fi
+
+exec docker run --rm -it \
+ --device /dev/dri \
+ -v "$OUT_DIR:/out" \
+ -v "$CACHE_DIR:/models" \
+ "$IMAGE" "$@"