* feat(playback): balance transcode sessions across multiple GPUs
playback.hw_device now accepts a comma-separated render-device list (e.g.
"/dev/dri/renderD128,/dev/dri/renderD129"). Each transcode session resolves
the list to one concrete device at spawn — the present device with the
fewest active GPU sessions, ties keeping list order — and holds that device
for its whole lifetime (seek/audio restarts reuse it); the reservation
releases on session shutdown, idempotently, including early spawn-failure
paths. Software-accel sessions never reserve, so they cannot skew the
balance.
A single configured value keeps the historical pass-through contract and an
empty value still auto-detects, so existing deployments are unaffected.
PickRenderDevice is list-aware too, picking least-loaded without reserving,
which lets the non-session consumers (chapter thumbnails, download
artifacts, transcode nodes) spread load best-effort when given a list.
Motivation: hosts with two identical media GPUs (e.g. dual Arc A310)
previously pinned every session to one device while the second sat idle.
* feat(admin): GPU device picker for playback hw_device
The hw-accel detection endpoint now reports render_device_details — each
render device with a human label derived from its sysfs PCI vendor/device
ids ("Intel GPU (0x56a6)") — and the Playback settings page renders them as
per-device toggles instead of requiring a hand-typed device path. No
selection means auto (first available device); one selection pins every
session; multiple selections balance least-loaded. The stored
playback.hw_device value stays the comma-separated list, written in stable
detection order regardless of click order, and a configured-but-undetected
device stays visible so a temporarily missing GPU is not silently dropped
on save.
* fix(playback): make GPU selection and reservation atomic
Review follow-up: resolveSessionHWDevice previously selected the
least-loaded device and incremented its count in two separate critical
sections, so concurrent session starts could all pick the same device
before any reservation landed. Device presence checks now happen outside
the lock and selection + reservation share one critical section; a
concurrency test asserts an exact split across two devices for eight
simultaneous starts, which the two-step version cannot guarantee.
* refactor(playback): one typed GPU acquisition boundary, release on process exit
Replace the CSV-handling spread across resolveSessionHWDevice and
PickRenderDevice with HWDeviceSet + AcquireHWDevice in hwdevice.go: every
GPU workload resolves exactly one device immediately before spawn.
Balancing is explicitly QSV/VAAPI-only — NVENC addresses GPUs by CUDA
index/UUID, so a multi-entry list warns and uses the first entry instead
of collapsing through the path-presence filter. Sessions now release
their reservation only after ffmpeg has been reaped (shutdown waits on
done first), closing the window where a new start could pick a device
the old process still occupied. Render-device sysfs descriptions move to
gpudetect.go so the allocator file owns only selection/reservation.
* fix(downloads): prepared downloads acquire a GPU through the shared pool
PrepareFile resolves the configured hw_device list to one concrete
device via AcquireHWDevice and holds the reservation until ffmpeg exits
(Run is synchronous, so the deferred release is the process-exit
boundary). Download encodes now participate in the same active-load
accounting as streaming sessions instead of best-effort spreading.
* fix(chapterthumbs): resolve hw_device list per extraction via the shared pool
ExtractFrame acquires one concrete device from AcquireHWDevice for the
hardware attempt (released when the attempt finishes) instead of passing
the raw comma-separated value to ffmpeg as a single device path. The
service stops pre-resolving and caching a device at first use — the raw
configured value flows through and each extraction resolves it.
* fix(transcodenode): fresh starts use this node's configured hw_device
/transcode/start constructed TranscodeOpts with an empty HWDevice, so
fresh sessions auto-detected the first GPU and bypassed the configured
list while reconstructed sessions honored it. Both paths now feed the
node-local config value into StartTranscode's shared resolution.
* feat(admin): node-aware GPU inventory on /admin/system/hw-accel
playback.hw_device is one cluster-wide value consumed by every transcode
node, but the endpoint probed only whichever healthy node had the fewest
jobs — an admin could configure devices that don't exist on the other
nodes. The endpoint now probes every healthy node concurrently and
returns a nodes array (URL, name, resolved accel, devices, or probe
error) alongside the backward-compatible flat fields, and the config doc
states the homogeneous-path contract explicitly.
* feat(admin): GPU picker survives empty detection, warns on node divergence
The picker rows are now the union of detected devices and configured
entries, so configured-but-missing devices stay visible (and
deselectable) when detection returns nothing or an older node omits
render_device_details (plain render_devices paths fall back to a generic
label). Per-node inventories from the hw-accel endpoint drive two
warnings: a banner when responding nodes report different device sets,
and a per-row note listing nodes missing that device. The multi-select
is hidden for NVENC — balancing is QSV/VA-API only — with a notice when
a multi-device value is already stored.
* style: gofmt touched files
* fix(playback): release GPU reservations on process exit
---------
Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
188 lines
6.6 KiB
Go
188 lines
6.6 KiB
Go
package playback
|
|
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Multi-GPU balancing: playback.hw_device accepts a comma-separated list of
|
|
// render devices (e.g. "/dev/dri/renderD128,/dev/dri/renderD129"). Every GPU
|
|
// workload (streaming sessions, prepared downloads, chapter thumbnails)
|
|
// resolves the list to exactly one concrete device via AcquireHWDevice
|
|
// immediately before launching ffmpeg, and releases it when that process has
|
|
// exited. Balancing is supported for the render-device accelerators (QSV and
|
|
// VAAPI) on a homogeneous device list; NVENC identifies GPUs by CUDA
|
|
// index/UUID rather than render-node path, so a multi-entry list falls back
|
|
// to its first entry. A single configured value keeps the historical
|
|
// pass-through contract for every accelerator, so existing deployments are
|
|
// unaffected.
|
|
|
|
// HWDeviceSet is the parsed form of the playback.hw_device setting: an
|
|
// ordered list of device entries. Order is priority order — ties in load
|
|
// resolve to the earlier entry.
|
|
type HWDeviceSet struct {
|
|
devices []string
|
|
}
|
|
|
|
// ParseHWDeviceSet splits a configured hw_device value into its device set,
|
|
// trimming whitespace and dropping empty entries.
|
|
func ParseHWDeviceSet(configured string) HWDeviceSet {
|
|
if strings.TrimSpace(configured) == "" {
|
|
return HWDeviceSet{}
|
|
}
|
|
parts := strings.Split(configured, ",")
|
|
devices := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
|
devices = append(devices, trimmed)
|
|
}
|
|
}
|
|
return HWDeviceSet{devices: devices}
|
|
}
|
|
|
|
// Empty reports whether no device is configured (auto-detection applies).
|
|
func (s HWDeviceSet) Empty() bool { return len(s.devices) == 0 }
|
|
|
|
// Multi reports whether more than one device is configured.
|
|
func (s HWDeviceSet) Multi() bool { return len(s.devices) > 1 }
|
|
|
|
// List returns the configured devices in priority order.
|
|
func (s HWDeviceSet) List() []string { return s.devices }
|
|
|
|
// First returns the first configured device, or "" when empty.
|
|
func (s HWDeviceSet) First() string {
|
|
if len(s.devices) == 0 {
|
|
return ""
|
|
}
|
|
return s.devices[0]
|
|
}
|
|
|
|
// hwDeviceStat reports whether a device path exists; overridable in tests.
|
|
var hwDeviceStat = func(path string) error {
|
|
_, err := os.Stat(path)
|
|
return err
|
|
}
|
|
|
|
// hwDeviceLoad tracks active GPU workloads per render device.
|
|
var hwDeviceLoad = struct {
|
|
mu sync.Mutex
|
|
counts map[string]int
|
|
}{counts: map[string]int{}}
|
|
|
|
// hwAccelBalancesRenderDevices reports whether the resolved acceleration mode
|
|
// selects GPUs by render-device path, which is what the balancer hands out.
|
|
// NVENC addresses GPUs by CUDA index/UUID and is deliberately excluded.
|
|
func hwAccelBalancesRenderDevices(hwAccel string) bool {
|
|
return hwAccel == "qsv" || hwAccel == "vaapi"
|
|
}
|
|
|
|
// presentHWDevices filters a device list to the entries that exist, falling
|
|
// back to the first entry when none do so the failure mode stays
|
|
// deterministic (ffmpeg reports the missing device, matching the historical
|
|
// wrong-path behavior of an explicit single value).
|
|
func presentHWDevices(devices []string) []string {
|
|
present := make([]string, 0, len(devices))
|
|
for _, device := range devices {
|
|
if hwDeviceStat(device) == nil {
|
|
present = append(present, device)
|
|
}
|
|
}
|
|
if len(present) == 0 {
|
|
return devices[:1]
|
|
}
|
|
return present
|
|
}
|
|
|
|
// leastLoadedHWDeviceLocked picks the device with the fewest active
|
|
// workloads, preserving list order on ties. Callers must hold hwDeviceLoad.mu.
|
|
func leastLoadedHWDeviceLocked(present []string) string {
|
|
best := present[0]
|
|
for _, device := range present[1:] {
|
|
if hwDeviceLoad.counts[device] < hwDeviceLoad.counts[best] {
|
|
best = device
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// newHWDeviceRelease returns an idempotent release for a reservation that has
|
|
// already been added to hwDeviceLoad.
|
|
func newHWDeviceRelease(device string) func() {
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
hwDeviceLoad.mu.Lock()
|
|
if hwDeviceLoad.counts[device] > 0 {
|
|
hwDeviceLoad.counts[device]--
|
|
}
|
|
hwDeviceLoad.mu.Unlock()
|
|
})
|
|
}
|
|
}
|
|
|
|
// reserveConcreteHWDevice reserves a device that was selected for an earlier
|
|
// process in the same transcode session. Restarts keep device affinity rather
|
|
// than running the least-loaded selection again.
|
|
func reserveConcreteHWDevice(device string) func() {
|
|
hwDeviceLoad.mu.Lock()
|
|
hwDeviceLoad.counts[device]++
|
|
count := hwDeviceLoad.counts[device]
|
|
hwDeviceLoad.mu.Unlock()
|
|
slog.Info("GPU workload device reserved", "device", device, "active_workloads", count)
|
|
return newHWDeviceRelease(device)
|
|
}
|
|
|
|
var nvencMultiDeviceWarnOnce sync.Once
|
|
|
|
// AcquireHWDevice resolves the configured hw_device value to exactly one
|
|
// device for one GPU workload. resolvedHWAccel must already be resolved (no
|
|
// "auto"). The returned release must be called exactly once when the ffmpeg
|
|
// process for this workload has exited; it is idempotent and a no-op for
|
|
// workloads that did not reserve (empty or single-device value, or an
|
|
// accelerator the balancer does not manage).
|
|
//
|
|
// - Empty value: returns "" so downstream auto-detection applies.
|
|
// - Single value: passes through unchanged for every accelerator.
|
|
// - Multi-device value with QSV/VAAPI: reserves the present device with the
|
|
// fewest active workloads (ties keep list order) until release.
|
|
// - Multi-device value with any other accelerator (including NVENC, which
|
|
// addresses GPUs by CUDA index/UUID, not render-node path): falls back to
|
|
// the first entry without reserving.
|
|
func AcquireHWDevice(configured, resolvedHWAccel string) (string, func()) {
|
|
noop := func() {}
|
|
set := ParseHWDeviceSet(configured)
|
|
if !set.Multi() {
|
|
return set.First(), noop
|
|
}
|
|
if !hwAccelBalancesRenderDevices(resolvedHWAccel) {
|
|
if resolvedHWAccel == "nvenc" {
|
|
nvencMultiDeviceWarnOnce.Do(func() {
|
|
slog.Warn("multi-device hw_device is not supported with NVENC (devices are CUDA index/UUID, not render paths); using the first entry",
|
|
"hw_device", configured, "using", set.First())
|
|
})
|
|
}
|
|
return set.First(), noop
|
|
}
|
|
// Select and reserve in one critical section so concurrent workload starts
|
|
// observe each other's reservations instead of piling onto one device.
|
|
present := presentHWDevices(set.List())
|
|
hwDeviceLoad.mu.Lock()
|
|
device := leastLoadedHWDeviceLocked(present)
|
|
hwDeviceLoad.counts[device]++
|
|
count := hwDeviceLoad.counts[device]
|
|
hwDeviceLoad.mu.Unlock()
|
|
slog.Info("GPU workload device selected", "device", device, "active_workloads", count)
|
|
|
|
return device, newHWDeviceRelease(device)
|
|
}
|
|
|
|
// hwDeviceActiveCount reports the active workload count for one device; test
|
|
// helper for asserting release boundaries.
|
|
func hwDeviceActiveCount(device string) int {
|
|
hwDeviceLoad.mu.Lock()
|
|
defer hwDeviceLoad.mu.Unlock()
|
|
return hwDeviceLoad.counts[device]
|
|
}
|