Skip to content

feat(playback): balance transcode sessions across multiple GPUs#425

Open
RXWatcher wants to merge 10 commits into
Silo-Server:mainfrom
RXWatcher:feat/multi-gpu-transcode
Open

feat(playback): balance transcode sessions across multiple GPUs#425
RXWatcher wants to merge 10 commits into
Silo-Server:mainfrom
RXWatcher:feat/multi-gpu-transcode

Conversation

@RXWatcher

@RXWatcher RXWatcher commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Part of #449.

Summary

playback.hw_device accepts a comma-separated render-device list (e.g. /dev/dri/renderD128,/dev/dri/renderD129). Every GPU workload — streaming transcode sessions, prepared downloads, chapter thumbnails — resolves the list to exactly one concrete device through a single acquisition boundary (AcquireHWDevice in internal/playback/hwdevice.go) immediately before ffmpeg launch: the present device with the fewest active workloads (ties keep list order). The reservation is held until that ffmpeg process has exited — sessions release after the process is reaped (seek/audio-switch restarts reuse the device), downloads and thumbnails release when their synchronous run returns.

Backward compatibility is exact: a single configured value keeps the historical pass-through contract for every accelerator, and an empty value still auto-detects.

Supported topology / compatibility assumptions

  • Balancing is QSV/VAAPI-only. NVENC identifies GPUs by CUDA index/UUID rather than render-node path; a multi-entry list with NVENC logs a warning and uses the first entry (covered by tests). Accelerator-specific device identities are out of scope for this version ([v1] Multi-GPU transcode balancing (playback.hw_device device list) #449).
  • The setting is cluster-wide. Every transcode node reads the same playback.hw_device, so multi-node deployments must expose the listed devices at identical paths on every node (documented on the config field). /admin/system/hw-accel now probes every healthy node and returns per-node inventories; the admin UI warns when node inventories diverge and marks devices missing on specific nodes.
  • Devices absent on a host fall out of that host's rotation; if none of the listed devices exist the first entry is used so the failure mode stays deterministic.

Review follow-ups addressed (from the changes-requested review)

  1. Fresh /transcode/start node sessions now feed the node-local configured value into the same resolution path as reconstruction — no more empty-device bypass.
  2. Streaming (StartTranscode), downloads (PrepareFile), and chapter thumbnails (ExtractFrame) all acquire through the one shared path with load accounting; thumbnails no longer pass a raw CSV to ffmpeg as a device path.
  3. Compatibility modeled explicitly: v1 constrained to homogeneous QSV/VAAPI render devices; NVENC multi-list behavior is explicit, warned, and tested (the old presence-filter collapse of 0,10 is gone because NVENC never enters the balancer).
  4. Sessions release the GPU reservation only after ffmpeg has been reaped (shutdown waits on the monitor goroutine first).
  5. Node-aware inventory endpoint + documented homogeneous-path contract (see above).
  6. The device picker renders the union of configured ∪ detected devices, so configured-but-missing devices stay visible when detection is empty or an older node omits render_device_details (falls back to plain render_devices paths).
  7. Typed HWDeviceSet parsed once; the allocator file (hwdevice.go) owns only selection/reservation; sysfs inventory and operator-facing descriptions live with GPU detection (gpudetect.go).
  8. Integration-level coverage: fresh node start resolution, thumbnail arg construction from a list, NVENC multi-list behavior, reservation release after process exit (and on spawn failure), all-devices-missing UI rows, heterogeneous node inventories (aggregation endpoint + divergence detection).
  9. pnpm run format:check, pnpm run lint, tsc -b, gofmt, go vet clean on touched files.

Testing

  • internal/playback/hwdevice_test.go: parse/acquire semantics incl. NVENC no-reserve, all-missing fallback, idempotent release, exact concurrent split under -race.
  • internal/playback/transcode_hwdevice_test.go: reservation held until CloseProcess reaps a real (fake-binary) ffmpeg; released on spawn failure.
  • internal/playback/prepare_file_test.go, internal/chapterthumbs/service_test.go: one concrete device in built args, never the raw list; release after run.
  • internal/transcodenode/server_test.go: fresh /transcode/start resolves the configured list.
  • internal/api/handlers/system_test.go: aggregation across healthy nodes, failed-probe entries, all-probes-fail local fallback.
  • web: 14 vitest cases for row union/fallback/divergence; pnpm run format:check green.
  • go test -race green for playback, chapterthumbs, transcodenode, api/handlers (the four NVENC ffmpeg-probe tests and two jellycompat process-lock tests fail on unmodified main on macOS; unrelated).

Admin UI screenshot: to follow.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added admin UI to configure playback.hw_device with per-device toggles, availability, and cluster safety warnings.
    • Enhanced GET /admin/system/hw-accel to aggregate probe results across all healthy transcode nodes, including per-node render-device inventories.
  • Bug Fixes
    • Ensured transcoding and frame extraction reserve a single concrete GPU device (never passes comma-lists to FFmpeg).
    • Improved GPU reservation lifecycle: holds until the FFmpeg process exits and is released on early failures.
  • Documentation / UX
    • Updated system payload and UI to surface richer render-device details.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds comma-separated GPU device configuration with load-aware reservation and release, expands hardware detection with device descriptions, aggregates hardware inventories across transcode nodes, and adds an admin UI for selecting and comparing render devices.

Changes

GPU device balancing

Layer / File(s) Summary
Device parsing and load-aware selection
internal/config/config.go, internal/playback/gpudetect.go, internal/playback/hwdevice.go, internal/playback/hwdevice_test.go
Adds multi-device parsing, qsv/vaapi load balancing, reservation tracking, NVENC handling, and render-device descriptions.
Transcode reservation lifecycle
internal/chapterthumbs/*, internal/playback/prepare_file*, internal/playback/transcode*, internal/transcodenode/*
Resolves one concrete device for extraction and transcoding, preserves reservations through ffmpeg execution, and releases them on failures or process exit.
Node hardware inventory API
internal/api/handlers/system.go, internal/api/handlers/system_test.go
Concurrently probes healthy transcode nodes, returns per-node inventories and errors, and falls back to local detection when needed.
Playback device picker
web/src/hooks/queries/admin/system.ts, web/src/pages/admin-settings/PlaybackSettings.tsx, web/src/pages/admin-settings/playbackSettings.utils.*
Adds typed inventory data, device-selection utilities, inventory divergence checks, and GPU device controls in playback settings.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Admin
  participant PlaybackSettings
  participant HandleHWAccel
  participant TranscodeNodes
  Admin->>PlaybackSettings: open playback settings
  PlaybackSettings->>HandleHWAccel: request hardware inventory
  HandleHWAccel->>TranscodeNodes: probe enabled healthy nodes
  TranscodeNodes-->>HandleHWAccel: return device paths and details
  HandleHWAccel-->>PlaybackSettings: return aggregate inventory
  PlaybackSettings-->>Admin: render device rows and warnings
Loading
sequenceDiagram
  participant TranscodeRequest
  participant StartTranscode
  participant AcquireHWDevice
  participant ffmpeg
  participant TranscodeSession
  TranscodeRequest->>StartTranscode: start with configured device list
  StartTranscode->>AcquireHWDevice: select and reserve one device
  StartTranscode->>ffmpeg: launch with resolved device
  ffmpeg-->>TranscodeSession: execute workload
  TranscodeSession->>AcquireHWDevice: release reservation after exit
Loading

Possibly related issues

Possibly related PRs

Suggested labels: v1

Suggested reviewers: quick104

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: balancing playback transcode sessions across multiple GPUs.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the v1 Silo v1 scope - auto-adds to the Silo v1 project label Jul 18, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/playback/hwdevicebalance.go`:
- Around line 98-105: Make hardware-device selection and reservation atomic in
the resolveSessionHWDevice flow: perform device availability checks before
acquiring hwDeviceLoad.mu, then select the least-loaded device and increment its
count within one critical section. Preserve the noop path for non-render-device
acceleration while ensuring concurrent sessions observe prior reservations;
reuse or extract the selection logic used by selectLeastLoadedHWDevice as
appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ca8d6a6c-393c-4b39-bb70-aebac1ccda04

📥 Commits

Reviewing files that changed from the base of the PR and between b96e359 and c640fa1.

📒 Files selected for processing (5)
  • internal/config/config.go
  • internal/playback/gpudetect.go
  • internal/playback/hwdevicebalance.go
  • internal/playback/hwdevicebalance_test.go
  • internal/playback/transcode.go

Comment thread internal/playback/hwdevicebalance.go Outdated
Comment on lines +98 to +105
device := selectLeastLoadedHWDevice(devices)
if !hwAccelUsesRenderDevice(hwAccel) {
return device, noop
}
hwDeviceLoad.mu.Lock()
hwDeviceLoad.counts[device]++
count := hwDeviceLoad.counts[device]
hwDeviceLoad.mu.Unlock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Time-of-check to time-of-use (TOCTOU) race condition in load balancing.

The selectLeastLoadedHWDevice function reads the load counts, but resolveSessionHWDevice separately acquires a lock to increment the count later. If multiple transcode sessions spawn concurrently, they will all read the same initial counts and select the same GPU before any of them commit the increment. This degrades the load balancer during bursty traffic by piling concurrent requests onto one device.

Consider performing the load evaluation and the increment atomically within a single hwDeviceLoad.mu critical section to ensure each concurrent request correctly sees the reservations made by the preceding ones.

💡 Example of atomic selection and acquisition

You can perform the os.Stat checks outside the lock, and then select and increment under a single lock:

	isHW := hwAccelUsesRenderDevice(hwAccel)
	
	// Filter present devices without holding the lock
	present := make([]string, 0, len(devices))
	for _, device := range devices {
		if hwDeviceStat(device) == nil {
			present = append(present, device)
		}
	}

	hwDeviceLoad.mu.Lock()
	
	device := devices[0]
	if len(present) > 0 {
		device = present[0]
		for _, d := range present[1:] {
			if hwDeviceLoad.counts[d] < hwDeviceLoad.counts[device] {
				device = d
			}
		}
	}

	if !isHW {
		hwDeviceLoad.mu.Unlock()
		return device, noop
	}

	hwDeviceLoad.counts[device]++
	count := hwDeviceLoad.counts[device]
	hwDeviceLoad.mu.Unlock()

(You may want to extract the inner comparison loop to a small private helper to keep it reusable for PickRenderDevice.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/playback/hwdevicebalance.go` around lines 98 - 105, Make
hardware-device selection and reservation atomic in the resolveSessionHWDevice
flow: perform device availability checks before acquiring hwDeviceLoad.mu, then
select the least-loaded device and increment its count within one critical
section. Preserve the noop path for non-render-device acceleration while
ensuring concurrent sessions observe prior reservations; reuse or extract the
selection logic used by selectLeastLoadedHWDevice as appropriate.

@RXWatcher

Copy link
Copy Markdown
Contributor Author

Added the admin UI on top: the hw-accel detection endpoint now reports render_device_details (per-device sysfs vendor/model labels), and Playback settings renders GPU devices as per-device toggles — none selected = auto, one = pin, several = balance. The stored playback.hw_device stays the comma list underneath, written in stable detection order. Utils covered by vitest; the three failing web test files on this branch fail identically on unmodified main.

@RXWatcher

Copy link
Copy Markdown
Contributor Author

Addressed the atomicity finding in b8595cc: presence checks moved outside the lock, selection + reservation now share one critical section, and a new -race-clean concurrency test asserts an exact 4/4 split for eight simultaneous session starts across two devices — which the previous two-step selection could not guarantee.

@Quick104 Quick104 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The architectural direction is sound: a transcode node should be able to schedule work across the compatible GPUs local to that host, rather than requiring one daemon per GPU. Multiple nodes should remain available for host/process isolation and heterogeneous policies.

I’m requesting changes because the current implementation does not apply that model consistently and is not safe across the full advertised surface.

Please address all of the following before this is approved:

  1. Make normal transcode-node sessions use the device pool. Fresh /transcode/start requests currently construct TranscodeOpts with HWDevice: "", so they auto-detect the first GPU and bypass the configured list. Reconstruction uses cfg.Playback.HWDevice, which makes fresh and reconstructed sessions inconsistent.

  2. Use one shared acquisition path for every GPU workload. Chapter thumbnails currently pass a non-empty comma-separated value directly to FFmpeg as one device path. Downloads are only best-effort and are not represented in the same active-load accounting. Streaming, prepared downloads, and thumbnails should all resolve exactly one compatible device immediately before process launch and release it at the correct lifecycle boundary.

  3. Model accelerator compatibility and device identity correctly. Presence plus session count is not enough. The UI exposes every DRM render node for QSV, VAAPI, NVENC, and auto, so mixed-vendor selections can pair an accelerator with an incompatible GPU. CUDA/NVENC device selection uses an index or UUID, not a /dev/dri/renderD* path; the current filesystem-presence filter also makes a manual 0,1 list collapse to device 0. Either explicitly constrain this first version to homogeneous compatible QSV/VAAPI render devices or introduce accelerator-specific device identities/capabilities.

  4. Hold reservations until FFmpeg has actually stopped. shutdown releases the GPU before cancelling the process and waiting for done, so another start can select the supposedly free device while the old FFmpeg process is still running.

  5. Fix the multi-node configuration model. The admin endpoint reports whichever healthy node currently has the fewest jobs, but saving writes one cluster-wide playback.hw_device value consumed by every node. This is only correct under an undocumented identical-device-path assumption. Make inventory/configuration node-aware, or explicitly design and validate the homogeneous-path contract.

  6. Keep configured devices visible when detection is empty or unavailable. The picker is gated on renderDevices.length > 0, so all configured-but-missing devices disappear when every device is absent or an older node does not return render_device_details.

  7. Simplify ownership. Keeping CSV as backward-compatible storage is reasonable, but parse it once into a typed internal device set. The allocator/reservation logic should have one small ownership boundary; sysfs inventory and operator-facing descriptions should remain with GPU detection rather than sharing hwdevicebalance.go.

  8. Add integration-level coverage for the real call paths. Please cover fresh transcode-node starts, chapter-thumbnail argument construction from a list, accelerator/device compatibility (including NVENC behavior or an explicit rejection), reservation release after process exit, all-devices-missing UI behavior, and heterogeneous node inventories. The current allocator unit tests and race test pass but do not exercise the paths where these failures occur.

  9. Complete the repository gates and PR requirements. pnpm run format:check currently fails on the three changed TS/TSX files. Please also link the relevant scope issue/capability item, add a screenshot or recording of the admin UI, and document the supported topology/compatibility assumptions.

Once these are addressed, the result should be both simpler operationally than one process per GPU and consistent with the existing node-pool architecture.

@RXWatcher

Copy link
Copy Markdown
Contributor Author

All nine items addressed in 8bee418…d08b162e; the PR description now carries the supported-topology section and a per-item map. The load-bearing changes:

  • One acquisition boundary (internal/playback/hwdevice.go): AcquireHWDevice(configured, resolvedAccel) is the only way any workload turns the configured value into a device. Streaming sessions, PrepareFile downloads, and chapter-thumbnail extraction all resolve exactly one device at launch and participate in the same load accounting; thumbnails no longer hand ffmpeg a raw CSV. PickRenderDevice is auto-detection-only again.
  • Release boundary (item 4): shutdown releases after waiting on the reap channel, so a device can't be re-selected while the old process is alive. Covered by a fake-binary test that asserts the count across CloseProcess.
  • Compatibility (item 3): took the constrain-v1 option — balancing is QSV/VAAPI render devices on a homogeneous list; NVENC multi-lists warn and use the first entry (tested), and the UI hides the multi-select for NVENC. CUDA index/UUID identities are deferred to [v1] Multi-GPU transcode balancing (playback.hw_device device list) #449.
  • Cluster contract (item 5): kept the single cluster-wide value, made it honest — /admin/system/hw-accel probes every healthy node and returns per-node inventories (flat fields unchanged for compatibility), the config field documents the identical-paths requirement, and the UI warns on divergence and marks per-row which nodes lack a device.
  • Item 1: fresh node starts now pass the node-local config value into the same resolution path as reconstruction.
  • Item 6: picker rows are configured ∪ detected, with a render_devices-only fallback for older nodes.

Scope issue: #449. Formatting/lint/vet green; test inventory is in the PR body. UI screenshot to follow.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
web/src/pages/admin-settings/playbackSettings.utils.ts (1)

97-106: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Picker rows are limited to the primary (first-responding) node's devices.

detectedDevices reads only the flat render_device_details/render_devices fields (the first successful probe), never detection.nodes[*]. A device present only on a secondary node has no row and can't be selected — the admin's only way to select it is if it's already in the stored hw_device value. This seems to match the stated design ("rows combine configured and detected devices"), so flagging for awareness rather than as a defect: consider whether the picker should offer the union of all responding nodes' devices (with the existing "missing on" warning covering the divergence case) so admins can discover node-specific hardware without pre-existing config.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/admin-settings/playbackSettings.utils.ts` around lines 97 -
106, Consider updating detectedDevices to include devices from every responding
entry in detection.nodes, while retaining configured-device rows and existing
missing-on warning behavior. Combine node-level render device details and legacy
render_devices into a deduplicated list, preserving the current primary-node
fallback when node data is unavailable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/chapterthumbs/service_test.go`:
- Around line 680-704: Update the device fixture in the test around
service.extractFrame so it does not depend on hardcoded host paths under
/dev/dri. Use unique nonexistent paths created beneath t.TempDir(), or stub the
device-selection behavior, while preserving the assertions that ffmpeg receives
one resolved device and never the raw comma-separated list.

---

Nitpick comments:
In `@web/src/pages/admin-settings/playbackSettings.utils.ts`:
- Around line 97-106: Consider updating detectedDevices to include devices from
every responding entry in detection.nodes, while retaining configured-device
rows and existing missing-on warning behavior. Combine node-level render device
details and legacy render_devices into a deduplicated list, preserving the
current primary-node fallback when node data is unavailable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f9b1aec1-7074-4650-97e3-4eba7b269537

📥 Commits

Reviewing files that changed from the base of the PR and between c640fa1 and d08b162.

📒 Files selected for processing (19)
  • internal/api/handlers/system.go
  • internal/api/handlers/system_test.go
  • internal/chapterthumbs/extractor.go
  • internal/chapterthumbs/service.go
  • internal/chapterthumbs/service_test.go
  • internal/config/config.go
  • internal/playback/gpudetect.go
  • internal/playback/hwdevice.go
  • internal/playback/hwdevice_test.go
  • internal/playback/prepare_file.go
  • internal/playback/prepare_file_test.go
  • internal/playback/transcode.go
  • internal/playback/transcode_hwdevice_test.go
  • internal/transcodenode/server.go
  • internal/transcodenode/server_test.go
  • web/src/hooks/queries/admin/system.ts
  • web/src/pages/admin-settings/PlaybackSettings.tsx
  • web/src/pages/admin-settings/playbackSettings.utils.test.ts
  • web/src/pages/admin-settings/playbackSettings.utils.ts

Comment on lines +680 to +704
hwAccel: "vaapi",
hwDevice: "/dev/dri/renderD888,/dev/dri/renderD889",
}
file := &models.MediaFile{FilePath: "/media/movie.mkv"}

var hwArgs []string
service.runFFmpegFrameExtractFunc = func(_ context.Context, _ string, args []string) ([]byte, error) {
if hwArgs == nil {
hwArgs = append([]string(nil), args...)
}
return []byte("frame"), nil
}

if _, _, err := service.extractFrame(context.Background(), file, 5, chapterThumbnailHDRPolicyBestEffort); err != nil {
t.Fatalf("extractFrame() error = %v", err)
}

joined := strings.Join(hwArgs, " ")
// Neither test device exists, so the balancer deterministically falls back
// to the first entry — but never passes the raw list to ffmpeg.
if strings.Contains(joined, "/dev/dri/renderD888,/dev/dri/renderD889") {
t.Fatalf("ffmpeg args contain the raw device list:\n%s", joined)
}
if !strings.Contains(joined, "/dev/dri/renderD888") {
t.Fatalf("ffmpeg args missing a resolved device:\n%s", joined)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the device fixture independent of host state.

The test assumes both hardcoded paths are absent, but that is not enforced. If /dev/dri/renderD889 exists while renderD888 does not, the balancer can correctly select renderD889 and this test fails. Use unique paths under t.TempDir() or stub device selection so the expected fallback is deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/chapterthumbs/service_test.go` around lines 680 - 704, Update the
device fixture in the test around service.extractFrame so it does not depend on
hardcoded host paths under /dev/dri. Use unique nonexistent paths created
beneath t.TempDir(), or stub the device-selection behavior, while preserving the
assertions that ffmpeg receives one resolved device and never the raw
comma-separated list.

RXWatcher added 10 commits July 21, 2026 18:14
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.
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.
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.
…ocess 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.
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.
…red 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.
/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.
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.
…gence

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.
@RXWatcher
RXWatcher force-pushed the feat/multi-gpu-transcode branch from d08b162 to cb2b429 Compare July 21, 2026 16:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handlers/system.go`:
- Around line 74-81: Wrap each per-node probe goroutine in the loop around
fetchRemoteHWAccel with a deferred recover, and record any recovered panic in
the corresponding errs[i] entry as a probe error while preserving the existing
infos[i] behavior. Ensure the panic is contained to that node’s probe and does
not escape the goroutine or crash the process.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 098c8ee3-f43f-4bc6-b0c5-0851cf34cad3

📥 Commits

Reviewing files that changed from the base of the PR and between d08b162 and cb2b429.

📒 Files selected for processing (19)
  • internal/api/handlers/system.go
  • internal/api/handlers/system_test.go
  • internal/chapterthumbs/extractor.go
  • internal/chapterthumbs/service.go
  • internal/chapterthumbs/service_test.go
  • internal/config/config.go
  • internal/playback/gpudetect.go
  • internal/playback/hwdevice.go
  • internal/playback/hwdevice_test.go
  • internal/playback/prepare_file.go
  • internal/playback/prepare_file_test.go
  • internal/playback/transcode.go
  • internal/playback/transcode_hwdevice_test.go
  • internal/transcodenode/server.go
  • internal/transcodenode/server_test.go
  • web/src/hooks/queries/admin/system.ts
  • web/src/pages/admin-settings/PlaybackSettings.tsx
  • web/src/pages/admin-settings/playbackSettings.utils.test.ts
  • web/src/pages/admin-settings/playbackSettings.utils.ts
🚧 Files skipped from review as they are similar to previous changes (18)
  • internal/playback/prepare_file.go
  • web/src/pages/admin-settings/playbackSettings.utils.test.ts
  • internal/config/config.go
  • web/src/pages/admin-settings/PlaybackSettings.tsx
  • internal/chapterthumbs/service.go
  • internal/playback/prepare_file_test.go
  • internal/playback/transcode_hwdevice_test.go
  • internal/chapterthumbs/extractor.go
  • internal/transcodenode/server.go
  • internal/api/handlers/system_test.go
  • web/src/hooks/queries/admin/system.ts
  • internal/playback/gpudetect.go
  • web/src/pages/admin-settings/playbackSettings.utils.ts
  • internal/transcodenode/server_test.go
  • internal/playback/hwdevice_test.go
  • internal/playback/hwdevice.go
  • internal/chapterthumbs/service_test.go
  • internal/playback/transcode.go

Comment on lines +74 to +81
for i, node := range healthy {
wg.Add(1)
go func() {
defer wg.Done()
infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
}()
}
wg.Wait()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Recover panics inside probe goroutines.

Each per-node probe goroutine has no recover(). A panic surfaced from fetchRemoteHWAccel (e.g. malformed/unexpected JSON from a remote node, a nil-deref in the HTTP client path) will unwind unrecovered and crash the entire process — not just this request — since panics in spawned goroutines are never caught by a recover in the calling goroutine. Given this fans out to every healthy node on every admin poll, one misbehaving node is enough to take down the whole control plane.

🛡️ Proposed fix: recover and record as a probe error
 	for i, node := range healthy {
 		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
-		}()
+		go func() {
+			defer wg.Done()
+			defer func() {
+				if rec := recover(); rec != nil {
+					errs[i] = fmt.Errorf("panic probing node: %v", rec)
+				}
+			}()
+			infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
+		}()
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for i, node := range healthy {
wg.Add(1)
go func() {
defer wg.Done()
infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
}()
}
wg.Wait()
for i, node := range healthy {
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if rec := recover(); rec != nil {
errs[i] = fmt.Errorf("panic probing node: %v", rec)
}
}()
infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
}()
}
wg.Wait()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handlers/system.go` around lines 74 - 81, Wrap each per-node
probe goroutine in the loop around fetchRemoteHWAccel with a deferred recover,
and record any recovered panic in the corresponding errs[i] entry as a probe
error while preserving the existing infos[i] behavior. Ensure the panic is
contained to that node’s probe and does not escape the goroutine or crash the
process.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v1 Silo v1 scope - auto-adds to the Silo v1 project

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants