Skip to main content

From silicon to stream: the hardware acceleration pipeline

This page traces the full hardware acceleration path in the homelab — what has to line up between an Intel iGPU sitting on a NUC motherboard and Jellyfin transcoding a 4K HEVC file in real time without melting a CPU core.

Five layers are involved, each owned by a different part of the repo, and each able to break the pipeline on its own. The actors:

ActorWhere it lives
Intel Iris Xe iGPU (in the i5-1340P NUCs)hardware/talos
Proxmox PCI passthrough (per-worker hostpci)foundation/proxmox · foundation/tofu
Talos i915 + intel-ucode system extensionsfoundation/talos · talos/talos/talconfig.yaml
Intel GPU device plugin (DaemonSet in kube-system)platform/intel-gpu-plugin
Kyverno policy exceptions for the pluginplatform/kyverno
Jellyfin (on-demand transcoding)apps/jellyfin
FileFlows (batch transcoding pipelines)apps/fileflows

The stack, bottom-up

One GPU per worker, three workers, and everything between the silicon and the container is declarative — the passthrough is OpenTofu, the kernel module is a Talos schematic line, the plugin is a Flux-managed DaemonSet, and the consumers are Kustomize patches.

Layer 1 — the silicon

Each of the three NUC 13 Pro nodes carries an Intel Core i5-1340P, whose integrated Iris Xe GPU includes Quick Sync fixed-function encode/decode blocks. That is the entire GPU fleet — there are no discrete cards. For a media-serving homelab this is the sweet spot: Quick Sync handles H.264/HEVC transcodes at a few watts, and the iGPU is already paid for.

The NUCs are Proxmox hosts, so the iGPU first belongs to the hypervisor — Kubernetes never sees the physical machine.

Layer 2 — Proxmox passes the GPU into the worker VM

Each Proxmox node runs one control-plane VM and one worker VM (see Cluster — talos). Only the workers get the GPU. In tofu/environment/production/talos.tf, the VM list carries a gpu flag — false for talos-cp-0{1,2,3}, true for talos-worker-0{1,2,3} — which drives a dynamic hostpci block:

dynamic "hostpci" {
for_each = each.value.gpu == true ? [1] : []
content {
device = "hostpci0"
id = "0000:00:02.0" # the iGPU's PCI address on every NUC
pcie = true
xvga = false
}
}

0000:00:02.0 is the iGPU's PCI address on all three (identical) hosts. xvga = false means the GPU is passed as a plain PCIe device, not as the VM's primary display — Talos has no use for a console framebuffer.

This is full passthrough, not SR-IOV or virtual functions: the worker VM owns the device exclusively, and the Proxmox host gives it up. The Proxmox page notes the operational caveat — after replacing a worker, re-confirm the IOMMU groups, because Proxmox device numbering can shift on kernel upgrades.

Layer 3 — Talos loads the driver

A passed-through PCI device is useless without a driver, and Talos ships no i915 module by default. The worker schematic in talos/talos/talconfig.yaml adds it:

worker:
schematic:
customization:
systemExtensions:
officialExtensions:
- siderolabs/i915 # intel quicksync
- siderolabs/intel-ucode # intel quicksync
- siderolabs/iscsi-tools # longhorn
- siderolabs/qemu-guest-agent
- siderolabs/util-linux-tools

siderolabs/i915 bundles the kernel module plus GPU firmware; siderolabs/intel-ucode keeps the CPU microcode current, which matters for the P-core/E-core hybrid. The control-plane schematic carries neither — no GPU, no driver. With the extension in place, the worker exposes /dev/dri (card + render nodes) like any ordinary Linux host. How schematics flow into machine config is covered on the Talos page.

Layer 4 — the device plugin makes it schedulable

Kubernetes doesn't know what /dev/dri is. The Intel GPU plugin bridges that gap: a raw DaemonSet (no Helm) in kube-system, defined in k8s/platform/base/controllers/intel-gpu-plugin/ and deployed by its own Flux Kustomization with wait: true. The cluster-wide infrastructure Kustomization lists it in dependsOn, so apps only reconcile once the plugin is healthy — Jellyfin never races the resource it wants to request.

The pod mounts four host paths, each with a job:

MountWhy
/dev/dri (ro)discover the GPU device nodes
/sys/class/drm (ro)read GPU properties
/var/lib/kubelet/device-pluginsthe kubelet gRPC socket it registers against
/var/run/cdiwrite CDI specs describing the devices to inject

The plugin runs with readOnlyRootFilesystem, all capabilities dropped, and no privilege escalation — but hostPath mounts and root still violate the cluster's Kyverno baseline, so three PolicyExceptions in k8s/platform/talos/configs/kyverno/exceptions/ (require-run-as-nonroot, host-path, volume-types) carve it out by name.

Once registered, each GPU worker advertises exactly one unit of gpu.intel.com/i915 (the manifest passes no -shared-dev-num argument, so the plugin's default of one container per GPU applies). The DaemonSet's nodeSelector is only kubernetes.io/arch: amd64 — on nodes without /dev/dri the plugin simply finds nothing and advertises nothing.

Layer 5 — scheduling and the consumers

gpu.intel.com/i915 is an extended resource: integer-only, no overcommit, and requests must equal limits. The scheduler treats it like any other capacity — a pod requesting one lands only on a node with a free slot, and the kubelet injects the device nodes into the container at start.

Cluster-wide budget: three slots (one per worker). Two are spoken for.

Jellyfin — on-demand transcoding

The talos overlay patch (k8s/apps/talos/jellyfin/jellyfin-patch.yaml) pins the GPU alongside memory:

resources:
requests:
cpu: "135m"
memory: "4162Mi"
gpu.intel.com/i915: 1
limits:
memory: "4162Mi"
gpu.intel.com/i915: 1

Two adjacent details make this work:

  • fsGroup: 104 in the pod securityContext puts the Jellyfin process in the render group, so it can actually open the injected /dev/dri render node.
  • strategy: Recreate in the base Deployment — a rolling update's surge pod would need a second GPU slot on the same node (plus the RWO config PVC), so the old pod must release both before the new one starts.

Inside the container, Jellyfin's Playback → Hardware acceleration is set to VA-API; most clients direct-play anyway, so the GPU only spins up for the lowest-common-denominator ones.

FileFlows — batch pipelines

FileFlows claims the second slot the same way (k8s/apps/talos/fileflows/patch-fileflows.yaml, gpu.intel.com/i915: 1 in requests and limits) and uses it for hardware-accelerated H.264/H.265 encoding in its processing flows — bulk re-encodes of the media library on the NFS stash mount, where throughput matters far more than for a single live stream.

Who deliberately doesn't use it

Immich's machine-learning workload (immich-machine-learning) runs CPU-only — no gpu.intel.com/i915 request anywhere in its manifests. Same for Tube Archivist. Smart-search embeddings and thumbnailing are batch jobs that tolerate CPU speed, and the third GPU slot stays free — headroom that also lets Jellyfin or FileFlows reschedule when a worker is drained.

Verifying the pipeline end-to-end

A throwaway checker pod lives at tofu/environment/production/gpu-checker-intel.yaml: an ffmpeg image that requests gpu.intel.com/i915: 1, installs vainfo and the intel-media-va-driver-non-free (iHD) driver, and dumps the VA-API profile list. If vainfo shows the encode entrypoints, every layer below it — passthrough, i915 extension, plugin, scheduling, device injection — is working. It is applied ad hoc, not managed by Flux.

What can break, and where to look

SymptomMost likely causeWhere to look first
Node advertises no gpu.intel.com/i915 capacityPlugin pod not running, or /dev/dri missing in the VMkubectl -n kube-system get ds intel-gpu-plugin; then the layers below
/dev/dri missing on a workerPassthrough lost after host maintenance (IOMMU renumbering), or the i915 extension fell out of the schematicfoundation/proxmox · talos/talos/talconfig.yaml
Jellyfin/FileFlows pod stuck Pending on Insufficient gpu.intel.com/i915All slots taken (one per worker), or a stale pod still holds the devicekubectl describe node allocations; remember shared-dev-num is 1
Jellyfin pod wedged mid-upgradeSurge pod waiting on the GPU slot / RWO PVC the old pod still holdsbase Deployment uses Recreate — check for a stuck terminating pod
Transcode falls back to software (CPU pegged)GPU scheduled but VA-API init failed inside the containerrun the gpu-checker pod; check Jellyfin playback settings
Plugin pod blocked by admissionKyverno exception drifted (plugin renamed / moved namespace)k8s/platform/talos/configs/kyverno/exceptions/ · platform/kyverno
Plugin image bump breaks registrationRenovate digest bump with plugin API changetopics/gitops-flow for the PR trail; plugin release notes

Why this is a topic, not a platform page

The platform page documents the device plugin itself — one component, one layer. But hardware acceleration only exists when five layers agree, and four of them live outside k8s/platform/: a tofu hostpci block, a Talos schematic line, Kyverno exceptions, and per-app resource patches. When transcoding breaks, the question is never "is the plugin broken?" — it's "which layer of this stack stopped agreeing?", and that diagnosis needs the whole picture on one page.