Skip to main content

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}