From eb3a6663d658f9a78eb91bfbddeea858c052c616 Mon Sep 17 00:00:00 2001 From: Pon-node Date: Wed, 15 Jul 2026 14:12:32 +0100 Subject: [PATCH] feat(transcode): add Intel VA-API (Arc) GPU backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full-GPU Intel VA-API path alongside the existing NVENC / CPU backends: - h264_vaapi / hevc_vaapi / av1_vaapi encoders in profiles.py (CQP rate control; VA-surface pixel format), selected via the existing --h264/h265/av1-encoder flags. - engine.py: -vaapi_device for GPU encode, and an optional HWACCEL_DECODE path (-hwaccel vaapi + scale_vaapi) that keeps decode + scale + encode all on the GPU. FFMPEG_BIN lets the image pick the ffmpeg build. - Dockerfile.intel on jellyfin-ffmpeg — distro ffmpeg's scale_vaapi / av1_qsv are broken on Arc; the jellyfin build ships the patched iHD + oneVPL stack. docker-compose.intel.yml wires it up with --device /dev/dri. - Dockerfile.gpu (NVENC CUDA base) to realize the README's "CUDA base" note. - runner.py advertises a non-NVIDIA GPU to discovery via RUNNER_GPU_* env, since register_runner auto-detects only NVIDIA. Verified on an Intel Arc A770: h264/hevc/av1 renditions all encode on the GPU; 10 concurrent AV1 sessions hold 2.7x realtime with ~92% less CPU than the sw-decode path. Co-Authored-By: Claude Opus 4.8 (1M context) --- transcode/Dockerfile.gpu | 19 ++++++++++++++ transcode/Dockerfile.intel | 35 +++++++++++++++++++++++++ transcode/README.md | 3 ++- transcode/docker-compose.intel.yml | 41 ++++++++++++++++++++++++++++++ transcode/engine.py | 28 ++++++++++++++++++-- transcode/profiles.py | 34 ++++++++++++++++++++----- transcode/runner.py | 19 +++++++++++--- 7 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 transcode/Dockerfile.gpu create mode 100644 transcode/Dockerfile.intel create mode 100644 transcode/docker-compose.intel.yml diff --git a/transcode/Dockerfile.gpu b/transcode/Dockerfile.gpu new file mode 100644 index 0000000..326abf8 --- /dev/null +++ b/transcode/Dockerfile.gpu @@ -0,0 +1,19 @@ +# NVIDIA NVENC build of the transcode app: CUDA runtime base + Ubuntu ffmpeg with +# av1_nvenc/h264_nvenc/hevc_nvenc built in. Requires the nvidia container runtime +# (--gpus all) at run time; set --h264/h265/av1-encoder to the *_nvenc variants. +FROM nvidia/cuda:12.6.3-runtime-ubuntu24.04 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ffmpeg python3 python3-pip python-is-python3 \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir --break-system-packages \ + "av" \ + "livepeer-gateway @ git+https://github.com/livepeer/livepeer-python-gateway@rs/media-publish-encoder-opts" + +WORKDIR /app +COPY runner.py profiles.py engine.py ./ + +EXPOSE 8990 + +CMD ["python3", "runner.py"] diff --git a/transcode/Dockerfile.intel b/transcode/Dockerfile.intel new file mode 100644 index 0000000..1baa857 --- /dev/null +++ b/transcode/Dockerfile.intel @@ -0,0 +1,35 @@ +# Intel VA-API build of the transcode app: jellyfin-ffmpeg7 ships the patched +# Intel media stack (iHD + oneVPL) that makes scale_vaapi + av1_vaapi work on Arc, +# where distro ffmpeg's are broken. Full-GPU (decode + scale + encode) via +# --device /dev/dri; set --h264/h265/av1-encoder to the *_vaapi variants (see +# docker-compose.intel.yml). +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl ca-certificates gnupg git python3 python3-pip python-is-python3 \ + && curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/ubuntu noble main" \ + > /etc/apt/sources.list.d/jellyfin.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends jellyfin-ffmpeg7 \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir --break-system-packages \ + "av" \ + "livepeer-gateway @ git+https://github.com/livepeer/livepeer-python-gateway@rs/media-publish-encoder-opts" + +# Point the engine at the jellyfin ffmpeg (patched Intel stack) and default to the +# full-GPU path (decode + scale + encode on the GPU). +ENV FFMPEG_BIN=/usr/lib/jellyfin-ffmpeg/ffmpeg \ + HWACCEL_DECODE=1 + +WORKDIR /app +COPY runner.py profiles.py engine.py ./ + +EXPOSE 8990 + +CMD ["python3", "runner.py"] diff --git a/transcode/README.md b/transcode/README.md index 335abf2..a29e82c 100644 --- a/transcode/README.md +++ b/transcode/README.md @@ -66,7 +66,8 @@ uv run client.py clip.mp4 --live --heights 720,360 # writes out-.ts ## Notes -- **AV1** is just `encoder: "AV1"` → `libsvtav1` (CPU) or `av1_nvenc` (GPU via `TRANSCODE_AV1_ENCODER` + a CUDA base). H.264/H.265 have the same override (`TRANSCODE_H264_ENCODER`/`TRANSCODE_H265_ENCODER` → `h264_nvenc`/`hevc_nvenc`); H.264 CPU (`libx264`) remains the default and needs no GPU. VP8/VP9 stay CPU-only — ffmpeg has no GPU encoder for either. +- **AV1** is just `encoder: "AV1"` → `libsvtav1` (CPU) or `av1_nvenc`/`av1_vaapi` (GPU via `TRANSCODE_AV1_ENCODER` — NVENC needs a CUDA base, VA-API uses `Dockerfile.intel`). H.264/H.265 have the same override (`TRANSCODE_H264_ENCODER`/`TRANSCODE_H265_ENCODER` → `h264_nvenc`/`hevc_nvenc` or `h264_vaapi`/`hevc_vaapi`); H.264 CPU (`libx264`) remains the default and needs no GPU. VP8/VP9 stay CPU-only — ffmpeg has no GPU encoder for either. +- **GPU backends**: **NVIDIA NVENC** builds from `Dockerfile.gpu` (CUDA base, `--gpus all`); **Intel VA-API** (Arc / iGPU) builds from `Dockerfile.intel` and runs via `docker-compose.intel.yml` with `--device /dev/dri`. The Intel image uses **jellyfin-ffmpeg** — distro ffmpeg's `scale_vaapi`/`av1_qsv` are broken on Arc. `HWACCEL_DECODE=1` (default there) keeps decode + scale + encode all on the GPU; `RENDER_DEVICE` picks the node (Arc dGPU is often `renderD129`). - **Multi-GPU**: batch jobs round-robin across every GPU the container can see (`nvidia-smi -L` at startup, `-gpu ` passed to any `*_nvenc` encoder) — `count: all` in the GPU reservation puts the whole box to work instead of pinning every job to device 0. - **Batch honors the full profile** (bitrate, profile, gop, pix fmt). **Live honors `height`/`fps`/`encoder`/`bitrate`/`profile`** via the SDK's per-track encoder-options support ([livepeer-python-gateway#35](https://github.com/livepeer/livepeer-python-gateway/pull/35), pinned in `pyproject.toml`/`Dockerfile`); only `gop` is still segment-driven on live. - The 54 TB AV1 archival job is the **batch** surface with `input_url`/`output_urls` + a fan-out driver over the clip list. diff --git a/transcode/docker-compose.intel.yml b/transcode/docker-compose.intel.yml new file mode 100644 index 0000000..f49917e --- /dev/null +++ b/transcode/docker-compose.intel.yml @@ -0,0 +1,41 @@ +# Offchain demo on Intel VA-API (Arc / iGPU): orchestrator + the transcode app +# built from Dockerfile.intel, full-GPU (decode + scale + encode) on /dev/dri. +# +# docker compose -f docker-compose.intel.yml up -d --build +# uv run client.py clip.mp4 --heights 720,360 +# +# RENDER_DEVICE picks the VA-API node (Arc dGPU is often renderD129, an iGPU +# renderD128). RUNNER_GPU_* (optional) advertise the GPU to orchestrator discovery. +services: + orchestrator: + extends: + file: ../compose.orchestrator.yml + service: orchestrator + + app: + build: + context: . + dockerfile: Dockerfile.intel + container_name: example_apps_transcode + depends_on: + orchestrator: + condition: service_healthy + devices: + - /dev/dri:/dev/dri + environment: + - RENDER_DEVICE=${RENDER_DEVICE:-/dev/dri/renderD128} + - HWACCEL_DECODE=1 + # Optional: advertise the GPU to discovery (SDK auto-detect is NVIDIA-only). + # - RUNNER_GPU_NAME=Intel Arc A770 + # - RUNNER_GPU_VRAM_MB=16384 + command: + - python3 + - runner.py + - --host=0.0.0.0 + - --orchestrator=https://orchestrator:8935 + - --orchSecret=abcdef + - --runner-url=http://app:8990 + - --capacity=${TRANSCODE_CAPACITY:-4} + - --av1-encoder=${TRANSCODE_AV1_ENCODER:-av1_vaapi} + - --h264-encoder=${TRANSCODE_H264_ENCODER:-h264_vaapi} + - --h265-encoder=${TRANSCODE_H265_ENCODER:-hevc_vaapi} diff --git a/transcode/engine.py b/transcode/engine.py index b20ca4b..b1f421d 100644 --- a/transcode/engine.py +++ b/transcode/engine.py @@ -11,6 +11,21 @@ import profiles as prof +# ffmpeg binary. Intel VA-API AV1 on Arc needs the patched media stack that +# jellyfin-ffmpeg ships (distro ffmpeg's scale_vaapi/av1_qsv are broken there), so +# Dockerfile.intel points FFMPEG_BIN at it; NVENC/CPU builds use plain ffmpeg. +FFMPEG = os.environ.get("FFMPEG_BIN", "ffmpeg") + +# VA-API render node (Intel). Override per host: an Arc dGPU is often renderD129, +# an integrated GPU renderD128. +RENDER_DEVICE = os.environ.get("RENDER_DEVICE", "/dev/dri/renderD128") + +# VA-API only: decode + scale on the GPU too (not just encode), so the whole +# pipeline stays on VA surfaces and the CPU is left near-idle. Off = sw decode + +# GPU encode, which is more forgiving on odd/corrupt inputs. Dockerfile.intel +# defaults it on. +HWACCEL_DECODE = os.environ.get("HWACCEL_DECODE", "0") == "1" + def transcode_file(input_path: str, raw_profiles: list[dict], out_dir: str, av1_encoder: str = "libsvtav1", h264_encoder: str = "libx264", @@ -28,13 +43,22 @@ def transcode_file(input_path: str, raw_profiles: list[dict], out_dir: str, for raw in raw_profiles: p = prof.normalize(raw, av1_encoder=av1_encoder, h264_encoder=h264_encoder, h265_encoder=h265_encoder) out_path = os.path.join(out_dir, f"{p['name']}.{p['ext']}") - cmd = ["ffmpeg", "-y"] + cmd = [FFMPEG, "-y"] if "nvenc" in p["encoder"]: cmd += ["-hwaccel", "cuda"] if gpu_index is not None: cmd += ["-hwaccel_device", str(gpu_index)] + elif "vaapi" in p["encoder"]: + if HWACCEL_DECODE: + # decode + scale + encode all on the GPU (VA surfaces end to end). + cmd += ["-hwaccel", "vaapi", "-hwaccel_device", RENDER_DEVICE, + "-hwaccel_output_format", "vaapi"] + else: + # sw decode, GPU encode (video_args uploads the frame to a VA surface). + cmd += ["-vaapi_device", RENDER_DEVICE] cmd += ["-i", input_path] - cmd += prof.video_args(p, gpu_index=gpu_index) + cmd += prof.video_args(p, gpu_index=gpu_index, + hw_decode=(HWACCEL_DECODE and "vaapi" in p["encoder"])) cmd += prof.audio_args(p["ext"]) if p["ext"] == "mp4": cmd += ["-movflags", "+faststart"] diff --git a/transcode/profiles.py b/transcode/profiles.py index 8ad1f96..44d2e98 100644 --- a/transcode/profiles.py +++ b/transcode/profiles.py @@ -23,7 +23,7 @@ "hevc": ("libx265", "mp4"), "vp8": ("libvpx", "webm"), "vp9": ("libvpx-vp9", "webm"), - "av1": ("libsvtav1", "mkv"), # GPU: override encoder=av1_nvenc via --av1-encoder + "av1": ("libsvtav1", "mkv"), # GPU: override encoder=av1_nvenc/av1_vaapi via --av1-encoder } # native H.264 profile name -> ffmpeg -profile:v value. @@ -88,14 +88,27 @@ def _pix_fmt(color_depth: int, chroma: int) -> str | None: return f"yuv{sub}p" + (f"{depth}le" if depth > 8 else "") -def video_args(p: dict[str, Any], gpu_index: int | None = None) -> list[str]: - """ffmpeg video flags for one normalized profile (scale + codec + rate control + gop).""" +def video_args(p: dict[str, Any], gpu_index: int | None = None, + hw_decode: bool = False) -> list[str]: + """ffmpeg video flags for one normalized profile (scale + codec + rate control + gop). + + hw_decode: VA-API only — when the input was hwaccel-decoded to a VA surface, + scale on the GPU (scale_vaapi) instead of the sw scale + upload. + """ args: list[str] = [] # resolution — 0 on either axis keeps aspect (-2 = even, preserve aspect). w = p["width"] or -2 h = p["height"] or -2 - args += ["-vf", f"scale={w}:{h}"] + if "vaapi" in p["encoder"] and hw_decode: + # frames are already VA surfaces (hwaccel decode): scale on the GPU. + # scale_vaapi wants concrete/-1 dims, so map the 0/-2 "keep aspect" to -1. + args += ["-vf", f"scale_vaapi=w={p['width'] or -1}:h={p['height'] or -1}"] + elif "vaapi" in p["encoder"]: + # sw scale, then upload nv12 to a VA surface for GPU encode. + args += ["-vf", f"scale={w}:{h},format=nv12,hwupload"] + else: + args += ["-vf", f"scale={w}:{h}"] args += ["-c:v", p["encoder"]] if gpu_index is not None and "nvenc" in p["encoder"]: @@ -114,6 +127,8 @@ def video_args(p: dict[str, Any], gpu_index: int | None = None) -> list[str]: crf = p["quality"] or _default_crf(p["encoder_key"]) if "nvenc" in p["encoder"]: args += ["-cq", str(crf)] + elif "vaapi" in p["encoder"]: + args += ["-rc_mode", "CQP", "-qp", str(crf)] # VA-API constant-quality elif p["encoder_key"] == "av1": args += ["-crf", str(crf), "-preset", "8"] # SVT-AV1 preset (speed/size) elif p["encoder_key"] in ("vp8", "vp9"): @@ -136,8 +151,15 @@ def video_args(p: dict[str, Any], gpu_index: int | None = None) -> list[str]: except ValueError: pass - pix = _pix_fmt(p["colorDepth"], p["chromaFormat"]) - if pix: + chroma = p["chromaFormat"] + if ("nvenc" in p["encoder"] or "vaapi" in p["encoder"]) and chroma != 0: + # NVENC/VA-API (h264/hevc/av1) on this fleet don't support 4:2:2/4:4:4 + # chroma; clamp to 4:2:0, keep requested bit depth. + chroma = 0 + pix = _pix_fmt(p["colorDepth"], chroma) + if pix and "vaapi" not in p["encoder"]: + # VA-API pins its pixel format via the VA surface (nv12); a second + # -pix_fmt would clash with the encoder's surface input. args += ["-pix_fmt", pix] return args diff --git a/transcode/runner.py b/transcode/runner.py index 4025f20..63283cc 100644 --- a/transcode/runner.py +++ b/transcode/runner.py @@ -39,7 +39,7 @@ import profiles as prof from aiohttp import ClientSession, ClientTimeout, web -from livepeer_gateway.live_runner import create_trickle_channels, register_runner +from livepeer_gateway.live_runner import LiveRunnerGPU, create_trickle_channels, register_runner from livepeer_gateway.media_output import MediaOutput from livepeer_gateway.media_publish import MediaPublish, MediaPublishConfig, VideoOutputConfig @@ -76,9 +76,9 @@ def _parse_args() -> argparse.Namespace: p.add_argument("--runner-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}") p.add_argument("--host", default=DEFAULT_HOST, help="Bind address (use 0.0.0.0 in containers).") p.add_argument("--capacity", type=int, default=4, help="Max concurrent jobs.") - p.add_argument("--av1-encoder", default="libsvtav1", help="Encoder for encoder=AV1 (libsvtav1 cpu, av1_nvenc gpu).") - p.add_argument("--h264-encoder", default="libx264", help="Encoder for encoder=H264/default (libx264 cpu, h264_nvenc gpu).") - p.add_argument("--h265-encoder", default="libx265", help="Encoder for encoder=H265/HEVC (libx265 cpu, hevc_nvenc gpu).") + p.add_argument("--av1-encoder", default="libsvtav1", help="Encoder for encoder=AV1 (libsvtav1 cpu, av1_nvenc/av1_vaapi gpu).") + p.add_argument("--h264-encoder", default="libx264", help="Encoder for encoder=H264/default (libx264 cpu, h264_nvenc/h264_vaapi gpu).") + p.add_argument("--h265-encoder", default="libx265", help="Encoder for encoder=H265/HEVC (libx265 cpu, hevc_nvenc/hevc_vaapi gpu).") p.add_argument("--price", type=int, default=0, help="Price in USD per pixels-per-unit (0 = free).") p.add_argument("--pixels-per-unit", type=int, default=1, help="Scale factor for the price.") p.add_argument("--no-register", action="store_true", @@ -259,9 +259,20 @@ async def _on_startup(app: web.Application) -> None: if args.no_register: log.info("static mode: not self-registering; attach via runners.json (health_url=/healthz)") return + # register_runner auto-detects only NVIDIA GPUs (pynvml/torch/nvidia-smi), + # so a non-NVIDIA GPU (e.g. Intel Arc on the VA-API path) is advertised to + # discovery via env instead — otherwise the runner shows with no GPU. + gpu = None + if os.environ.get("RUNNER_GPU_NAME"): + gpu = LiveRunnerGPU( + id=os.environ.get("RUNNER_GPU_ID", ""), + name=os.environ["RUNNER_GPU_NAME"], + vram_mb=int(os.environ.get("RUNNER_GPU_VRAM_MB", "0") or 0), + ) app["registration"] = await register_runner( args.orchestrator, secret=args.orchSecret, runner_url=args.runner_url, app=APP_ID, capacity=args.capacity, price_per_unit=args.price, pixels_per_unit=args.pixels_per_unit, + gpu=gpu, auto_detect_gpu=(gpu is None), ) log.info("registered runner_id=%s app=%s capacity=%d", app["registration"].runner_id, APP_ID, args.capacity)