rivet/multigpu/mod.rs
1//! Multi-GPU reactive variant phase — **the rung benefit**.
2//!
3//! Decode the source **once** and dynamically schedule every rung's CMAF
4//! segments across all available GPUs using a fair lease pool with mid-flight
5//! helper dispatch:
6//!
7//! ```text
8//! decode pump (decode once)
9//! │ fan out normalized frames
10//! ▼
11//! per-rung scaler ──► SegmentChunkQueue ──► encoder worker (holds a GpuLease)
12//! ──► helper worker (claims a freed lease)
13//! ```
14//!
15//! - One encoder per GPU at a time ([`GpuPool`] enforces it — concurrent
16//! NVENC sessions on one context deadlock).
17//! - A fast rung releases its lease early; the **helper dispatcher** grabs the
18//! freed lease and attaches an extra worker to a still-busy rung, so a slow
19//! rung finishes sooner. Segment work is the unit of parallelism.
20//! - Helpers may land on a different GPU **vendor** than the rung's first
21//! worker; the per-rung AV1 **codec invariant** ([`RungCodecInvariant`])
22//! guarantees every contributed segment shares the `av1C` contract, so a
23//! cross-vendor (NVENC + QSV) rendition still decodes cleanly. A mismatched
24//! helper requeues its chunk and exits — the run never aborts on it.
25//!
26//! Storage/transport specifics stay out of the engine: progress is reported
27//! through the generic [`ProgressSink`], so a consumer can layer an uploader
28//! (object storage, a status queue, …) on top by watching `RungStatus::Completed`.
29
30mod gpu_policy;
31mod hls;
32mod single_file;
33
34pub use gpu_policy::{detect_gpu_pool, gpu_pool_for_policy, policy_gpu_indices, serial_gpu_for_policy};
35pub use hls::run_multigpu_hls;
36pub use single_file::{RungPackets, run_multigpu_single_file};
37
38use std::path::PathBuf;
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
41
42use bytes::Bytes;
43use codec::frame::{ColorMetadata, PixelFormat, VideoCodec};
44use container::cmaf::CmafTrackManifest;
45use container::streaming::DemuxHeader;
46
47use crate::decode_pump::{ClipSource, DecodePumpConfig};
48use crate::gpu_pool::GpuPool;
49use crate::progress::{ProgressSink, RungProgress, RungStatus};
50use crate::spec::Rung;
51
52pub(super) const QUEUE_CAPACITY: usize = 2;
53pub(super) const FANOUT_CHANNEL_CAPACITY: usize = 4;
54pub(super) const HELPER_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200);
55pub(super) const PROGRESS_TICK: std::time::Duration = std::time::Duration::from_millis(500);
56
57/// One rung's finalized CMAF manifest.
58#[derive(Debug, Clone)]
59pub struct RungManifest {
60 pub rung_index: usize,
61 pub width: u32,
62 pub height: u32,
63 pub label: String,
64 /// Directory relative to the asset root, e.g. `"video/720p"`.
65 pub relative_dir: String,
66 pub manifest: CmafTrackManifest,
67}
68
69/// Inputs to [`run_multigpu_hls`].
70pub struct MultiGpuParams<'a> {
71 pub input: Bytes,
72 /// Output video codec — drives the per-worker encoder dispatch, the codec
73 /// invariant parse, and the stitch muxer's sample-entry choice.
74 pub codec: VideoCodec,
75 pub rungs: &'a [Rung],
76 pub header: DemuxHeader,
77 pub source_color_metadata: ColorMetadata,
78 pub source_pixel_format: PixelFormat,
79 /// Whether the decode pump tonemaps HDR→SDR (from the spec's `ColorPolicy`).
80 pub tonemap_to_sdr: bool,
81 /// Resolved **output** color metadata + pixel format the encoders target
82 /// (from `OutputSpec::resolve_output`).
83 pub output_color_metadata: ColorMetadata,
84 pub output_pixel_format: PixelFormat,
85 pub needs_downsample: bool,
86 /// Prepared per-frame video filter chain applied in the decode pump (before
87 /// scaling). Overlay images are loaded once at prepare time.
88 pub filters: Arc<codec::filter::FilterChain>,
89 pub frame_rate: f64,
90 pub gpu_pool: Arc<GpuPool>,
91 /// GPU indices the encode policy selected, in detection order. The decode
92 /// pump pins to these (round-robin for per-rung pumps) so decode honors the
93 /// same `Family` / `SingleGpu` / `AllGpus` constraint as encode. Empty ⇒
94 /// the decoder dispatch auto-selects (legacy behavior).
95 pub gpu_indices: Vec<u32>,
96 /// Explicit decode-pump GPU override. `Some(i)` forces every decode pump
97 /// onto GPU `i` regardless of `gpu_indices`; `None` follows the policy.
98 pub decode_gpu: Option<u32>,
99 pub output_root: PathBuf,
100 pub timescale: u32,
101 pub per_frame_ticks: u32,
102 pub keyframe_interval: u32,
103 pub segment_target_ticks: u64,
104 pub total_input_frames: u64,
105 /// Force constant-QP chunk encoding (single-file `ChunkSeamMode::ParallelConstQp`)
106 /// so stitched chunk seams are quality-flat. `false` for HLS (segments are
107 /// independent) and the default `Parallel` single-file mode.
108 pub constant_qp: bool,
109 /// Decode plan for the HLS pump: one entry per spliced clip, each carrying
110 /// its own decoder config + `[start_frame, end_frame)` trim range. A single
111 /// whole-input clip is the un-spliced case — behaviourally identical to the
112 /// old single-input pump (`run_shared_*` is a one-whole-clip wrapper). The
113 /// per-clip `cfg.gpu_index` is a placeholder; `clip_sources_for` overrides
114 /// it with each pump's GPU. Unused by the single-file multi-GPU path (which
115 /// decodes from `input`).
116 pub spliced_clips: Vec<ClipSource>,
117}
118
119impl MultiGpuParams<'_> {
120 /// Resolve the decode-pump GPU for the `i`-th per-rung pump (or the shared
121 /// pump when `i == 0`): the explicit `decode_gpu` override wins, else the
122 /// policy's GPU indices round-robin, else `None` (decoder auto-select).
123 pub(super) fn decode_gpu_for(&self, i: usize) -> Option<u32> {
124 if self.decode_gpu.is_some() {
125 return self.decode_gpu;
126 }
127 if self.gpu_indices.is_empty() {
128 return None;
129 }
130 Some(self.gpu_indices[i % self.gpu_indices.len()])
131 }
132
133 /// Per-clip decode sources for a pump pinned to `gpu`. When `spliced_clips`
134 /// is empty (the un-spliced case) this is one whole clip built from `input`
135 /// + the header — behaviourally identical to the old single-input pump.
136 /// Otherwise it clones the splice plan, overriding each clip's `gpu_index`
137 /// so every pump honours its assigned GPU while keeping the per-clip
138 /// codec / color / trim.
139 pub(super) fn clip_sources_for(&self, gpu: Option<u32>) -> Vec<ClipSource> {
140 if self.spliced_clips.is_empty() {
141 return vec![ClipSource {
142 cfg: DecodePumpConfig {
143 codec_name: self.header.codec.clone(),
144 info_for_decoder: self.header.info.clone(),
145 source_color_metadata: self.source_color_metadata,
146 source_pixel_format: self.source_pixel_format,
147 needs_downsample: self.needs_downsample,
148 tonemap_to_sdr: self.tonemap_to_sdr,
149 gpu_index: gpu,
150 filters: self.filters.clone(),
151 },
152 input: self.input.clone(),
153 start_frame: 0,
154 end_frame: None,
155 }];
156 }
157 self.spliced_clips
158 .iter()
159 .map(|c| ClipSource {
160 cfg: DecodePumpConfig { gpu_index: gpu, ..c.cfg.clone() },
161 input: c.input.clone(),
162 start_frame: c.start_frame,
163 end_frame: c.end_frame,
164 })
165 .collect()
166 }
167}
168
169/// Per-job constants shared by every encoder worker.
170#[derive(Clone)]
171pub(super) struct WorkerCtx {
172 pub(super) codec: VideoCodec,
173 pub(super) frame_rate: f64,
174 pub(super) output_color_metadata: ColorMetadata,
175 pub(super) output_pixel_format: PixelFormat,
176 pub(super) timescale: u32,
177 pub(super) per_frame_ticks: u32,
178 pub(super) keyframe_interval: u32,
179 pub(super) segment_target_ticks: u64,
180 pub(super) output_root: PathBuf,
181 pub(super) constant_qp: bool,
182}
183
184/// Periodic per-rung progress reporter. Reads the shared frame counters and
185/// emits `Running` updates until stopped; skips rungs already finalized.
186pub(super) fn spawn_progress_reporter(
187 rungs: Vec<Rung>,
188 frames_encoded: Vec<Arc<AtomicU64>>,
189 finalized: Arc<Vec<AtomicBool>>,
190 total_input_frames: u64,
191 sink: Arc<dyn ProgressSink>,
192 stop: Arc<AtomicBool>,
193) -> tokio::task::JoinHandle<()> {
194 tokio::spawn(async move {
195 loop {
196 if stop.load(Ordering::Acquire) {
197 break;
198 }
199 tokio::time::sleep(PROGRESS_TICK).await;
200 for (idx, rung) in rungs.iter().enumerate() {
201 if finalized[idx].load(Ordering::Acquire) {
202 continue;
203 }
204 let done = frames_encoded[idx].load(Ordering::Relaxed);
205 report(
206 sink.as_ref(),
207 idx,
208 rung,
209 RungStatus::Running,
210 done,
211 Some(total_input_frames),
212 0,
213 0,
214 None,
215 );
216 }
217 }
218 })
219}
220
221#[allow(clippy::too_many_arguments)]
222pub(super) fn report(
223 sink: &dyn ProgressSink,
224 rung_index: usize,
225 rung: &Rung,
226 status: RungStatus,
227 frames_done: u64,
228 frames_total: Option<u64>,
229 segments: u32,
230 bytes_out: u64,
231 message: Option<String>,
232) {
233 let percent = match status {
234 RungStatus::Completed => 100.0,
235 RungStatus::Pending => 0.0,
236 _ => match frames_total {
237 Some(t) if t > 0 => ((frames_done as f32 / t as f32) * 100.0).min(99.0),
238 _ => 1.0,
239 },
240 };
241 sink.on_rung(RungProgress {
242 rung_index,
243 label: rung.label.clone(),
244 width: rung.width,
245 height: rung.height,
246 status,
247 percent,
248 frames_done,
249 frames_total,
250 segments_written: segments,
251 bytes_out,
252 message,
253 });
254}