mod common;
use std::{
path::Path,
sync::atomic::Ordering,
thread,
time::{Duration, Instant},
};
use common::{MIB, TempDir, Trend, iterations, settle, soak_duration, try_test_video};
use ffmpeg_next as ffmpeg;
use media_pp::{
bus::BusEvent,
color::Color,
elements::{
FileDemuxer, FileMuxer, FrameCounter, SegmentPolicy, SegmentedFileMuxer, SwDecoder,
SwEncoder, SwEncoderOptions, SwVideoCompositor, TeeBuilder, TestVideoOptions,
TestVideoSource, VideoCodec, VideoCompositorOptions, VideoFit, VideoLayer, VideoRect,
},
pipeline::{Pipeline, SeekMode},
};
macro_rules! isolate {
() => {{
fn probe() {}
fn type_name_of<T>(_: T) -> &'static str {
std::any::type_name::<T>()
}
let full = type_name_of(probe);
let path = full.strip_suffix("::probe").unwrap_or(full);
let name = path.split_once("::").map_or(path, |(_crate, rest)| rest);
if crate::common::spawn_isolated(name) {
return;
}
}};
}
const WARMUP: usize = 3;
const WIDTH: u32 = 320;
const HEIGHT: u32 = 240;
fn frame_rate() -> ffmpeg::Rational {
ffmpeg::Rational::new(30, 1)
}
fn test_source(name: &str) -> TestVideoSource {
TestVideoSource::new(
name,
TestVideoOptions {
width: WIDTH,
height: HEIGHT,
framerate: frame_rate(),
},
)
}
#[derive(Clone, Copy, PartialEq)]
enum Teardown {
Finish,
Stop,
}
impl Teardown {
fn for_cycle(cycle: usize) -> Self {
if cycle.is_multiple_of(2) {
Self::Finish
} else {
Self::Stop
}
}
fn apply(self, pipeline: &Pipeline) {
match self {
Self::Finish => pipeline.finish(),
Self::Stop => pipeline.stop(),
}
let events: Vec<_> = pipeline.bus().iter().collect();
assert_no_errors(&events);
}
}
fn assert_no_errors(events: &[BusEvent]) {
let errors: Vec<_> = events
.iter()
.filter(|event| matches!(event, BusEvent::Error { .. }))
.collect();
assert!(errors.is_empty(), "unexpected error event(s): {errors:?}");
}
fn encoder(name: &str, time_base: ffmpeg::Rational, gop_size: u32) -> SwEncoder {
SwEncoder::new(
name,
SwEncoderOptions {
codec: VideoCodec::OpenH264,
width: WIDTH,
height: HEIGHT,
time_base,
frame_rate: frame_rate(),
bit_rate: 1_000_000,
gop_size,
max_b_frames: None,
},
)
.expect("libopenh264 encoder")
}
fn open_fixture(path: &str) -> (FileDemuxer, usize, ffmpeg::codec::Parameters) {
let (source, streams) = FileDemuxer::open("demux", path).expect("open the fixture");
let index = streams
.iter()
.find(|stream| stream.kind == ffmpeg::media::Type::Video)
.expect("the fixture has a video stream")
.index;
let parameters = source
.stream_parameters(index)
.expect("the stream the demuxer just listed");
(source, index, parameters)
}
fn record_once(path: &Path, teardown: Teardown) {
let source = test_source("video");
let time_base = source.time_base();
let encoder = encoder("encoder", time_base, 30);
let mut muxer = FileMuxer::create(path).expect("create the recording");
muxer
.add_stream("video", encoder.parameters(), time_base)
.expect("add the video track");
let sink = muxer
.open()
.expect("open the recording")
.pop()
.expect("one track");
let pipeline = Pipeline::new("soak-record", source, |source, ctx| {
let branch = ctx
.branch()
.queue("encode-frames", 8)
.pipe(encoder)
.to(sink)?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the recording pipeline");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&pipeline);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn repeated_record_cycles_do_not_grow_process_memory() {
isolate!();
let _exclusive = common::exclusive();
media_pp::init().expect("ffmpeg init");
let dir = TempDir::new("record-cycles");
let iterations = iterations(20);
let mut memory = Trend::private_bytes("record cycle private bytes");
for cycle in 0..(WARMUP + iterations) {
let path = dir.join("cycle.mp4");
record_once(&path, Teardown::for_cycle(cycle));
assert!(path.is_file(), "cycle {cycle} produced no recording");
std::fs::remove_file(&path)
.expect("the muxer must have closed the file before the cycle ended");
if cycle + 1 == WARMUP {
settle();
}
if cycle >= WARMUP {
memory.sample();
}
}
memory.assert_flat(0.5 * MIB);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn pause_resume_storm_does_not_grow_process_memory() {
isolate!();
let _exclusive = common::exclusive();
media_pp::init().expect("ffmpeg init");
let (counter, frames) = FrameCounter::new("counter");
let pipeline = Pipeline::new("soak-control", test_source("video"), |source, ctx| {
let branch = ctx.branch().queue("frames", 8).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the control-storm pipeline");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(200));
let iterations = iterations(60);
let mut memory = Trend::private_bytes("control storm private bytes");
for round in 0..(WARMUP + iterations) {
pipeline.pause();
thread::sleep(Duration::from_millis(20));
pipeline.resume();
thread::sleep(Duration::from_millis(30));
if round + 1 == WARMUP {
settle();
}
if round >= WARMUP {
memory.sample();
}
}
let before_teardown = frames.load(Ordering::Relaxed);
pipeline.finish();
assert!(
before_teardown > 0,
"the storm paused the pipeline so hard it never delivered a frame"
);
memory.assert_flat(0.25 * MIB);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn seek_storm_does_not_grow_process_memory() {
isolate!();
let _exclusive = common::exclusive();
media_pp::init().expect("ffmpeg init");
let Some(path) = try_test_video() else { return };
let (source, index, parameters) = open_fixture(&path);
let (counter, frames) = FrameCounter::new("counter");
let pipeline = Pipeline::new("soak-seek", source, |source, ctx| {
let decoder = SwDecoder::new("decoder", parameters)?;
let branch = ctx
.branch()
.pipe(decoder)
.queue("frames", 8)
.to(Box::new(counter))?;
ctx.attach(source, index, branch)?;
Ok(())
})
.expect("wire the seek-storm pipeline");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(200));
let iterations = iterations(30);
let mut memory = Trend::private_bytes("seek storm private bytes");
for round in 0..(WARMUP + iterations) {
let target = if round % 2 == 0 {
Duration::from_millis(200)
} else {
Duration::from_millis(1200)
};
pipeline.seek(target, SeekMode::Accurate).expect("seek");
thread::sleep(Duration::from_millis(60));
if round + 1 == WARMUP {
settle();
}
if round >= WARMUP {
memory.sample();
}
}
assert!(
frames.load(Ordering::Relaxed) > 0,
"the storm seeked so hard nothing ever decoded"
);
pipeline.stop();
let events: Vec<_> = pipeline.bus().iter().collect();
assert_no_errors(&events);
memory.assert_flat(0.5 * MIB);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn tee_branch_churn_does_not_grow_process_memory() {
isolate!();
let _exclusive = common::exclusive();
media_pp::init().expect("ffmpeg init");
let (fixed_counter, fixed_frames) = FrameCounter::new("fixed-counter");
let mut tee_handle = None;
let pipeline = Pipeline::new("soak-tee", test_source("video"), |source, ctx| {
let fixed = ctx.branch().to(Box::new(fixed_counter))?;
let (tee_branch, handle) = TeeBuilder::new("tee", ctx.clone())
.branch(fixed)
.build_dynamic()?;
ctx.attach(source, 0, tee_branch)?;
tee_handle = Some(handle);
Ok(())
})
.expect("wire the tee-churn pipeline");
let tee_handle = tee_handle.expect("the wire closure provides the handle");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(200));
let iterations = iterations(40);
let mut memory = Trend::private_bytes("tee churn private bytes");
for round in 0..(WARMUP + iterations) {
let (counter, _frames) = FrameCounter::new("churn-counter");
let branch = tee_handle
.branch()
.expect("the tee is alive while its pipeline runs")
.to(Box::new(counter))
.expect("build the runtime branch");
let id = tee_handle
.attach(branch)
.expect("attach the runtime branch");
thread::sleep(Duration::from_millis(40));
tee_handle.detach(id).expect("detach the runtime branch");
if round + 1 == WARMUP {
settle();
}
if round >= WARMUP {
memory.sample();
}
}
assert_eq!(
tee_handle.sink_count(),
1,
"every churned branch must be gone, leaving only the fixed one"
);
assert!(
fixed_frames.load(Ordering::Relaxed) > 0,
"the fixed branch must keep receiving frames throughout the churn"
);
pipeline.finish();
memory.assert_flat(0.5 * MIB);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn compositor_input_churn_does_not_grow_process_memory() {
isolate!();
let _exclusive = common::exclusive();
media_pp::init().expect("ffmpeg init");
let (compositor, handle) = SwVideoCompositor::new(
"compositor",
VideoCompositorOptions {
width: WIDTH,
height: HEIGHT,
frame_rate: frame_rate(),
background: Color::new(16, 16, 16),
},
)
.expect("create the compositor");
let (counter, frames) = FrameCounter::new("counter");
let output = Pipeline::new("soak-compositor", compositor, |source, ctx| {
let branch = ctx.branch().queue("composited", 4).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the compositor output pipeline");
output.run().unwrap();
let iterations = iterations(20);
let mut memory = Trend::private_bytes("compositor churn private bytes");
for round in 0..(WARMUP + iterations) {
let mut layer = VideoLayer::new(VideoRect::new(0, 0, WIDTH, HEIGHT));
layer.fit = VideoFit::Cover;
let input = handle
.add_source("churned", layer)
.expect("add a compositor input")
.expect("the compositor is alive while its pipeline runs");
let feeder = Pipeline::new("soak-compositor-input", test_source("input"), {
let sink = input.sink;
move |source, ctx| {
let branch = ctx.branch().to(sink)?;
ctx.attach(source, 0, branch)?;
Ok(())
}
})
.expect("wire the compositor input pipeline");
feeder.run().unwrap();
thread::sleep(Duration::from_millis(150));
feeder.finish();
handle.remove_source("churned");
assert_eq!(
handle.source_count(),
0,
"round {round} left a compositor input behind"
);
if round + 1 == WARMUP {
settle();
}
if round >= WARMUP {
memory.sample();
}
}
assert!(
frames.load(Ordering::Relaxed) > 0,
"the compositor must keep emitting while its inputs churn"
);
output.finish();
memory.assert_flat(1.0 * MIB);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn a_running_compositor_does_not_grow_while_it_answers_frames() {
isolate!();
let _exclusive = common::exclusive();
media_pp::init().expect("ffmpeg init");
let (compositor, handle) = SwVideoCompositor::new(
"compositor",
VideoCompositorOptions {
width: WIDTH,
height: HEIGHT,
frame_rate: frame_rate(),
background: Color::new(16, 16, 16),
},
)
.expect("create the compositor");
let mut layer = VideoLayer::new(VideoRect::new(0, 0, WIDTH, HEIGHT));
layer.fit = VideoFit::Cover;
let input = handle
.add_source("moving", layer)
.expect("add a compositor input")
.expect("the compositor is alive");
let (counter, frames) = FrameCounter::new("counter");
let output = Pipeline::new("soak-running-compositor", compositor, |source, ctx| {
let branch = ctx.branch().queue("composited", 4).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the compositor output pipeline");
let feeder = Pipeline::new("soak-running-input", test_source("input"), {
let sink = input.sink;
move |source, ctx| {
let branch = ctx.branch().to(sink)?;
ctx.attach(source, 0, branch)?;
Ok(())
}
})
.expect("wire the compositor input pipeline");
output.run().unwrap();
feeder.run().unwrap();
let duration = soak_duration(20);
let sample_interval = Duration::from_secs(1);
let deadline = Instant::now() + duration;
let mut memory = Trend::private_bytes("running compositor private bytes");
thread::sleep(sample_interval);
settle();
let started = frames.load(Ordering::Relaxed);
while Instant::now() < deadline {
thread::sleep(sample_interval);
memory.sample();
}
let composited = frames.load(Ordering::Relaxed) - started;
feeder.finish();
output.finish();
assert!(
composited > 0,
"the compositor stopped emitting, so a flat trend proves nothing"
);
eprintln!("composited {composited} frames over {duration:?}");
memory.assert_flat(0.5 * MIB);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn segment_rotation_does_not_grow_process_memory_or_hold_files() {
isolate!();
let _exclusive = common::exclusive();
media_pp::init().expect("ffmpeg init");
let dir = TempDir::new("segments");
let source = test_source("video");
let time_base = source.time_base();
let encoder = encoder("encoder", time_base, 15);
let segment_dir = dir.path().to_path_buf();
let mut muxer = SegmentedFileMuxer::create(
SegmentPolicy::Duration(Duration::from_secs(1)),
move |index| segment_dir.join(format!("segment_{index:04}.mp4")),
);
muxer.add_stream("video", encoder.parameters(), time_base);
let sink = muxer
.open()
.expect("open the first segment")
.pop()
.expect("one track");
let pipeline = Pipeline::new("soak-segments", source, |source, ctx| {
let branch = ctx
.branch()
.queue("encode-frames", 8)
.pipe(encoder)
.to(sink)?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the segmented pipeline");
pipeline.run().unwrap();
let duration = soak_duration(20);
let sample_interval = Duration::from_secs(1);
let deadline = Instant::now() + duration;
let mut memory = Trend::private_bytes("segment rotation private bytes");
thread::sleep(sample_interval);
settle();
while Instant::now() < deadline {
thread::sleep(sample_interval);
memory.sample();
}
pipeline.finish();
let segments: Vec<_> = std::fs::read_dir(dir.path())
.expect("read the segment directory")
.map(|entry| entry.expect("read a segment entry").path())
.collect();
assert!(
segments.len() > 1,
"the policy never actually rotated: {} file(s) in {duration:?}",
segments.len()
);
for segment in &segments {
assert!(
std::fs::metadata(segment).expect("stat a segment").len() > 0,
"{} was left empty",
segment.display()
);
std::fs::remove_file(segment).expect("a finalized segment must be closed");
}
memory.assert_flat(0.5 * MIB);
}
#[cfg(all(windows, feature = "d3d11"))]
mod d3d11 {
use std::{
sync::{Arc, Mutex, atomic::Ordering},
thread,
time::{Duration, Instant},
};
use ffmpeg_next as ffmpeg;
use media_pp::{
color::Color,
elements::{
ChromaKeyMethod, ChromaKeyOptions, D3d11ChromaKey, D3d11Decoder, D3d11Scaler,
D3d11ScalerFormat, D3d11Upload, D3d11VideoCodec, D3d11VideoCompositor,
D3d11VideoEncoder, D3d11VideoEncoderOptions, D3d11VideoInputFormat, FrameCounter,
PacketCounter, SwScaler, VideoCompositorOptions, VideoLayer, VideoRect,
},
pipeline::Pipeline,
};
use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11DeviceContext};
use crate::common::{
MIB, Trend, Unit, exclusive,
gpu::{D3d11LiveObjects, try_d3d11_device, vram_bytes},
iterations, settle, soak_duration, try_test_video,
};
use crate::{HEIGHT, Teardown, WARMUP, WIDTH, frame_rate, test_source};
const WARMUP_SECS: Duration = Duration::from_secs(20);
const SCALED_WIDTH: u32 = 176;
const SCALED_HEIGHT: u32 = 144;
fn cycle(
device: &ID3D11Device,
context: &Arc<Mutex<ID3D11DeviceContext>>,
teardown: Teardown,
) -> usize {
let (counter, frames) = FrameCounter::new("counter");
let device = device.clone();
let context = context.clone();
let pipeline = Pipeline::new("soak-d3d11", test_source("video"), move |source, ctx| {
let to_nv12 = SwScaler::new(
"to-nv12",
ffmpeg::format::Pixel::NV12,
WIDTH,
HEIGHT,
ffmpeg::software::scaling::Flags::BILINEAR,
);
let upload = D3d11Upload::new("upload", &device, WIDTH, HEIGHT);
let scaler = D3d11Scaler::new(
"scaler",
&device,
context.clone(),
D3d11ScalerFormat::Preserve,
SCALED_WIDTH,
SCALED_HEIGHT,
)?;
let branch = ctx
.branch()
.pipe(to_nv12)
.pipe(upload)
.pipe(scaler)
.queue("gpu-frames", 4)
.to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the D3D11 pipeline");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
fn chroma_key_cycle(
device: &ID3D11Device,
context: &Arc<Mutex<ID3D11DeviceContext>>,
teardown: Teardown,
) -> usize {
let (compositor, compositor_handle) = D3d11VideoCompositor::new(
"compositor",
device,
context.clone(),
VideoCompositorOptions {
width: WIDTH,
height: HEIGHT,
frame_rate: frame_rate(),
background: Color::new(0, 255, 0),
},
)
.expect("build the compositor");
let layer_sink = compositor_handle
.add_source(
"layer",
VideoLayer::new(VideoRect::new(0, 0, WIDTH, HEIGHT)),
)
.expect("register the compositor input")
.expect("the compositor is alive")
.sink;
let input_device = device.clone();
let input_pipeline = Pipeline::new("soak-d3d11-key-input", test_source("video"), {
move |source, ctx| {
let to_nv12 = SwScaler::new(
"to-nv12",
ffmpeg::format::Pixel::NV12,
WIDTH,
HEIGHT,
ffmpeg::software::scaling::Flags::BILINEAR,
);
let upload = D3d11Upload::new("upload", &input_device, WIDTH, HEIGHT);
let branch = ctx.branch().pipe(to_nv12).pipe(upload).to(layer_sink)?;
ctx.attach(source, 0, branch)?;
Ok(())
}
})
.expect("wire the compositor input pipeline");
let (counter, frames) = FrameCounter::new("counter");
let key_device = device.clone();
let key_context = context.clone();
let output_pipeline = Pipeline::new("soak-d3d11-key", compositor, move |source, ctx| {
let key = D3d11ChromaKey::new(
"key",
&key_device,
key_context,
ChromaKeyOptions {
method: ChromaKeyMethod::Green,
threshold: 0.15,
smoothing: 0.1,
},
)?;
let branch = ctx
.branch()
.pipe(key)
.queue("keyed-frames", 4)
.to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the chroma-key pipeline");
output_pipeline.run().unwrap();
input_pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&input_pipeline);
teardown.apply(&output_pipeline);
frames.load(Ordering::Relaxed)
}
const DECODE_QUEUE_DEPTH: usize = 4;
fn decode_cycle(device: &ID3D11Device, path: &str, teardown: Teardown) -> usize {
let (source, index, parameters) = crate::open_fixture(path);
let (counter, frames) = FrameCounter::new("counter");
let device = device.clone();
let pipeline = Pipeline::new("soak-d3d11-decode", source, move |source, ctx| {
let decoder =
D3d11Decoder::new("decoder", parameters, &device, DECODE_QUEUE_DEPTH as i32)?;
let branch = ctx
.branch()
.pipe(decoder)
.queue("decoded-frames", DECODE_QUEUE_DEPTH)
.to(Box::new(counter))?;
ctx.attach(source, index, branch)?;
Ok(())
})
.expect(
"wire the D3D11 decode pipeline — a failure here after the first cycle means \
an earlier decoder never released its fixed-size surface pool",
);
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
fn nvenc_options(time_base: ffmpeg::Rational) -> D3d11VideoEncoderOptions {
D3d11VideoEncoderOptions {
codec: D3d11VideoCodec::H264Nvenc,
input_format: D3d11VideoInputFormat::Nv12,
width: WIDTH,
height: HEIGHT,
time_base,
frame_rate: frame_rate(),
bit_rate: 1_000_000,
gop_size: 30,
max_b_frames: None,
}
}
fn encode_cycle(
device: &ID3D11Device,
context: &Arc<Mutex<ID3D11DeviceContext>>,
teardown: Teardown,
) -> usize {
let (counter, packets) = PacketCounter::new("counter");
let source = test_source("video");
let time_base = source.time_base();
let device = device.clone();
let context = context.clone();
let pipeline = Pipeline::new("soak-d3d11-nvenc", source, move |source, ctx| {
let to_nv12 = SwScaler::new(
"to-nv12",
ffmpeg::format::Pixel::NV12,
WIDTH,
HEIGHT,
ffmpeg::software::scaling::Flags::BILINEAR,
);
let upload = D3d11Upload::new("upload", &device, WIDTH, HEIGHT);
let encoder = D3d11VideoEncoder::new(
"encoder",
&device,
context.clone(),
nvenc_options(time_base),
)?;
let branch = ctx
.branch()
.pipe(to_nv12)
.pipe(upload)
.queue("gpu-frames", 4)
.pipe(encoder)
.to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect(
"wire the D3D11 NVENC pipeline — a failure here after the first cycle means an \
earlier encoder never closed its NVENC session",
);
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&pipeline);
packets.load(Ordering::Relaxed)
}
fn nvenc_supported(device: &ID3D11Device, context: &Arc<Mutex<ID3D11DeviceContext>>) -> bool {
let time_base = test_source("probe").time_base();
match D3d11VideoEncoder::new("probe", device, context.clone(), nvenc_options(time_base)) {
Ok(_) => true,
Err(error) => {
eprintln!("skipping: no D3D11 NVENC encoder on this machine ({error})");
false
}
}
}
fn decode_supported(device: &ID3D11Device, path: &str) -> bool {
let (_source, _index, parameters) = crate::open_fixture(path);
match D3d11Decoder::new("probe", parameters, device, 0) {
Ok(_) => true,
Err(error) => {
eprintln!("skipping: no D3D11VA decoder for this fixture ({error})");
false
}
}
}
struct Budget {
warmup: usize,
iterations: usize,
memory: Option<f64>,
vram: f64,
}
fn measure_cycles(
label: &str,
device: &ID3D11Device,
live: Option<Arc<D3d11LiveObjects>>,
budget: Budget,
mut cycle: impl FnMut(Teardown) -> usize,
) {
let Budget {
warmup,
iterations,
memory: max_memory_slope,
vram: max_vram_slope,
} = budget;
let mut memory = Trend::private_bytes(format!("{label} private bytes"));
let mut vram = Trend::new(format!("{label} adapter memory"), Unit::Bytes, {
let device = device.clone();
move || vram_bytes(&device)
});
let mut objects = live.clone().map(|live| {
Trend::new(format!("{label} live objects"), Unit::Objects, move || {
live.count()
})
});
for index in 0..(warmup + iterations) {
let teardown = Teardown::for_cycle(index);
let frames = cycle(teardown);
if teardown == Teardown::Finish {
assert!(
frames > 0,
"{label} {index} pushed nothing through the GPU path before draining"
);
}
if index + 1 == warmup {
settle();
}
if index >= warmup {
memory.sample();
vram.sample();
if let Some(objects) = objects.as_mut() {
objects.sample();
}
}
}
match max_memory_slope {
Some(max) => memory.assert_flat(max),
None => {
memory.print();
eprintln!(
" recorded, not asserted: the graphics driver's own allocation cache \
dominates this gauge here and saturates at a different cycle every run \
(see Budget); adapter memory carries this scenario's assertion"
);
}
}
vram.assert_flat(max_vram_slope);
let Some(objects) = objects else {
return;
};
if objects.slope() > 0.0 {
let live = live.expect("the trend exists only with the debug layer");
eprintln!("live D3D11 objects after the last cycle:");
for line in live.describe() {
eprintln!(" {line}");
}
}
objects.assert_flat(0.0);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn upload_and_scale_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some((device, context, live)) = try_d3d11_device() else {
return;
};
measure_cycles(
"d3d11 cycle",
&device,
live.map(Arc::new),
Budget {
warmup: WARMUP,
iterations: iterations(15),
memory: Some(0.5 * MIB),
vram: 1.0 * MIB,
},
|teardown| cycle(&device, &context, teardown),
);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn chroma_key_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some((device, context, live)) = try_d3d11_device() else {
return;
};
measure_cycles(
"d3d11 chroma key cycle",
&device,
live.map(Arc::new),
Budget {
warmup: WARMUP,
iterations: iterations(15),
memory: Some(0.5 * MIB),
vram: 1.0 * MIB,
},
|teardown| chroma_key_cycle(&device, &context, teardown),
);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn a_running_d3d11_compositor_does_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some((device, context, live)) = try_d3d11_device() else {
return;
};
let (compositor, handle) = D3d11VideoCompositor::new(
"compositor",
&device,
context.clone(),
VideoCompositorOptions {
width: WIDTH,
height: HEIGHT,
frame_rate: frame_rate(),
background: Color::new(16, 16, 16),
},
)
.expect("build the compositor");
let layer_sink = handle
.add_source(
"moving",
VideoLayer::new(VideoRect::new(0, 0, WIDTH, HEIGHT)),
)
.expect("register the compositor input")
.expect("the compositor is alive")
.sink;
let input_device = device.clone();
let feeder = Pipeline::new("soak-running-d3d11-input", test_source("video"), {
move |source, ctx| {
let to_nv12 = SwScaler::new(
"to-nv12",
ffmpeg::format::Pixel::NV12,
WIDTH,
HEIGHT,
ffmpeg::software::scaling::Flags::BILINEAR,
);
let upload = D3d11Upload::new("upload", &input_device, WIDTH, HEIGHT);
let branch = ctx.branch().pipe(to_nv12).pipe(upload).to(layer_sink)?;
ctx.attach(source, 0, branch)?;
Ok(())
}
})
.expect("wire the compositor input pipeline");
let (counter, frames) = FrameCounter::new("counter");
let output = Pipeline::new("soak-running-d3d11", compositor, |source, ctx| {
let branch = ctx.branch().queue("composited", 4).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the compositor output pipeline");
output.run().unwrap();
feeder.run().unwrap();
let duration = soak_duration(20);
let sample_interval = Duration::from_secs(1);
let mut memory = Trend::private_bytes("running d3d11 compositor private bytes");
let mut vram = Trend::new("running d3d11 compositor adapter memory", Unit::Bytes, {
let device = device.clone();
move || vram_bytes(&device)
});
let mut objects = live.map(Arc::new).map(|live| {
Trend::new(
"running d3d11 compositor live objects",
Unit::Objects,
move || live.count(),
)
});
thread::sleep(WARMUP_SECS);
settle();
let started = frames.load(Ordering::Relaxed);
let deadline = Instant::now() + duration;
while Instant::now() < deadline {
thread::sleep(sample_interval);
memory.sample();
vram.sample();
if let Some(objects) = objects.as_mut() {
objects.sample();
}
}
let composited = frames.load(Ordering::Relaxed) - started;
feeder.finish();
output.finish();
assert!(
composited > 0,
"the compositor stopped emitting, so a flat trend proves nothing"
);
eprintln!("composited {composited} frames over {duration:?}");
memory.assert_flat(0.5 * MIB);
vram.assert_flat(1.0 * MIB);
if let Some(objects) = objects {
objects.print();
eprintln!(
" recorded, not asserted: a running graph creates D3D11 objects per frame — an \
upload texture and the shader-resource views a draw binds — and dropping the \
last reference only queues one for destruction, so this gauge climbs and then \
falls back wholesale when the driver flushes. It measures that queue rather \
than what this graph owns; the cycle scenarios above are where it is an \
ownership gauge, and adapter memory carries the assertion here"
);
}
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn decode_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(path) = try_test_video() else { return };
let Some((device, _context, live)) = try_d3d11_device() else {
return;
};
if !decode_supported(&device, &path) {
return;
}
measure_cycles(
"d3d11 decode cycle",
&device,
live.map(Arc::new),
Budget {
warmup: WARMUP,
iterations: iterations(10),
memory: Some(0.5 * MIB),
vram: 2.0 * MIB,
},
|teardown| decode_cycle(&device, &path, teardown),
);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn nvenc_encode_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some((device, context, live)) = try_d3d11_device() else {
return;
};
if !nvenc_supported(&device, &context) {
return;
}
measure_cycles(
"d3d11 nvenc cycle",
&device,
live.map(Arc::new),
Budget {
warmup: WARMUP,
iterations: iterations(10),
memory: Some(0.5 * MIB),
vram: 2.0 * MIB,
},
|teardown| encode_cycle(&device, &context, teardown),
);
}
#[cfg(any(feature = "dxgi-capture", feature = "wgc-capture"))]
const CAPTURE_WARMUP: usize = 10;
#[cfg(feature = "dxgi-capture")]
fn capture_cycle(mode: media_pp::elements::CaptureMode, teardown: Teardown) -> usize {
use media_pp::elements::{CaptureArea, DxgiCaptureOptions, DxgiCaptureSource};
let (counter, frames) = FrameCounter::new("counter");
let (source, _format, _device) = DxgiCaptureSource::open(
"capture",
DxgiCaptureOptions {
area: CaptureArea::Output { output_index: 0 },
capture_mode: mode,
..Default::default()
},
)
.expect(
"open desktop duplication — a failure here after the first cycle means an earlier \
capture source never released its output duplication",
);
let pipeline = Pipeline::new("soak-dxgi-capture", source, |source, ctx| {
let branch = ctx.branch().queue("captured", 4).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the capture pipeline");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(400));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
#[cfg(feature = "dxgi-capture")]
fn capture_supported(mode: media_pp::elements::CaptureMode) -> bool {
use media_pp::elements::{CaptureArea, DxgiCaptureOptions, DxgiCaptureSource};
match DxgiCaptureSource::open(
"probe",
DxgiCaptureOptions {
area: CaptureArea::Output { output_index: 0 },
capture_mode: mode,
..Default::default()
},
) {
Ok(_) => true,
Err(error) => {
eprintln!("skipping: no desktop duplication available here ({error})");
false
}
}
}
#[test]
#[ignore = "soak test; run with --ignored"]
#[cfg(feature = "dxgi-capture")]
fn cpu_desktop_capture_cycles_do_not_grow_process_memory() {
use media_pp::elements::CaptureMode;
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let cpu_mode = || CaptureMode::Cpu {
include_cursor: false,
};
if !capture_supported(cpu_mode()) {
return;
}
let Some((device, _context, _live)) = try_d3d11_device() else {
return;
};
measure_cycles(
"cpu capture cycle",
&device,
None,
Budget {
warmup: WARMUP,
iterations: iterations(10),
memory: Some(0.5 * MIB),
vram: 1.0 * MIB,
},
|teardown| capture_cycle(cpu_mode(), teardown),
);
}
#[test]
#[ignore = "soak test; run with --ignored"]
#[cfg(feature = "dxgi-capture")]
fn gpu_desktop_capture_cycles_do_not_grow_gpu_memory() {
use media_pp::elements::CaptureMode;
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
if !capture_supported(CaptureMode::Gpu) {
return;
}
let Some((device, _context, _live)) = try_d3d11_device() else {
return;
};
measure_cycles(
"gpu capture cycle",
&device,
None,
Budget {
warmup: CAPTURE_WARMUP,
iterations: iterations(10),
memory: None,
vram: 1.0 * MIB,
},
|teardown| capture_cycle(CaptureMode::Gpu, teardown),
);
}
#[cfg(feature = "wgc-capture")]
struct WgcTestWindow {
hwnd: usize,
thread: Option<std::thread::JoinHandle<()>>,
}
#[cfg(feature = "wgc-capture")]
impl WgcTestWindow {
fn create() -> windows::core::Result<Self> {
use windows::{
Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DestroyWindow, DispatchMessageW, GetMessageW, IsWindow, MSG,
TranslateMessage, WINDOW_EX_STYLE, WS_OVERLAPPEDWINDOW, WS_VISIBLE,
},
core::w,
};
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
let thread = thread::spawn(move || {
let created = unsafe {
CreateWindowExW(
WINDOW_EX_STYLE::default(),
w!("STATIC"),
w!("media-pp WGC soak window"),
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
0,
0,
320,
240,
None,
None,
None,
None,
)
};
let hwnd = match created {
Ok(hwnd) => hwnd,
Err(error) => {
let _ = ready_tx.send(Err(error));
return;
}
};
if ready_tx.send(Ok(hwnd.0 as usize)).is_err() {
let _ = unsafe { DestroyWindow(hwnd) };
return;
}
let mut message = MSG::default();
while unsafe { GetMessageW(&mut message, None, 0, 0) }.0 > 0 {
unsafe {
let _ = TranslateMessage(&message);
DispatchMessageW(&message);
}
if !unsafe { IsWindow(Some(hwnd)) }.as_bool() {
break;
}
}
if unsafe { IsWindow(Some(hwnd)) }.as_bool() {
let _ = unsafe { DestroyWindow(hwnd) };
}
});
let hwnd = ready_rx
.recv()
.expect("WGC window thread stopped before reporting readiness")?;
Ok(Self {
hwnd,
thread: Some(thread),
})
}
fn hwnd(&self) -> windows::Win32::Foundation::HWND {
windows::Win32::Foundation::HWND(self.hwnd as *mut _)
}
}
#[cfg(feature = "wgc-capture")]
impl Drop for WgcTestWindow {
fn drop(&mut self) {
use windows::Win32::{
Foundation::{LPARAM, WPARAM},
UI::WindowsAndMessaging::{PostMessageW, WM_CLOSE},
};
let _ = unsafe {
PostMessageW(
Some(self.hwnd()),
WM_CLOSE,
WPARAM::default(),
LPARAM::default(),
)
};
if let Some(thread) = self.thread.take() {
thread.join().expect("WGC window thread panicked");
}
}
}
#[cfg(feature = "wgc-capture")]
fn wgc_capture_cycle(hwnd: windows::Win32::Foundation::HWND, teardown: Teardown) -> usize {
use media_pp::elements::{WgcCaptureOptions, WgcCaptureSource};
use windows::Win32::UI::WindowsAndMessaging::{
SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOZORDER, SetWindowPos,
};
static CYCLE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let (counter, frames) = FrameCounter::new("counter");
let (source, _device) = WgcCaptureSource::open(
"window-capture",
hwnd,
WgcCaptureOptions {
fps: 30,
include_cursor: false,
},
)
.expect("open WGC source");
let pipeline = Pipeline::new("soak-wgc-capture", source, |source, ctx| {
let branch = ctx.branch().queue("captured", 4).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire WGC pipeline");
pipeline.run().expect("start WGC pipeline");
let cycle = CYCLE.fetch_add(1, Ordering::Relaxed);
let width = if cycle.is_multiple_of(2) { 320 } else { 321 };
unsafe {
SetWindowPos(
hwnd,
None,
0,
0,
width,
240,
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE,
)
}
.expect("resize WGC target window");
thread::sleep(Duration::from_millis(750));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
#[test]
#[ignore = "soak test; requires an interactive Windows Graphics Capture session"]
#[cfg(feature = "wgc-capture")]
fn window_capture_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let window = match WgcTestWindow::create() {
Ok(window) => window,
Err(error) => {
eprintln!("skipping: cannot create WGC target window ({error})");
return;
}
};
let Some((gauge_device, _context, _live)) = try_d3d11_device() else {
return;
};
measure_cycles(
"WGC window capture cycle",
&gauge_device,
None,
Budget {
warmup: CAPTURE_WARMUP,
iterations: iterations(10),
memory: None,
vram: 1.0 * MIB,
},
|teardown| wgc_capture_cycle(window.hwnd(), teardown),
);
}
}
#[cfg(all(windows, feature = "d3d12"))]
mod d3d12 {
use std::{sync::atomic::Ordering, thread, time::Duration};
use ffmpeg_next as ffmpeg;
use media_pp::{
elements::{D3d12Download, D3d12Scaler, D3d12Upload, FrameCounter, SwScaler},
pipeline::Pipeline,
};
use windows::Win32::Graphics::{
Direct3D::D3D_FEATURE_LEVEL_11_0,
Direct3D12::{D3D12CreateDevice, ID3D12Device},
};
use crate::common::{Trend, Unit, exclusive, gpu::d3d12_vram_bytes, iterations, settle};
use crate::{HEIGHT, Teardown, WARMUP, WIDTH, test_source};
const SCALED_WIDTH: u32 = 176;
const SCALED_HEIGHT: u32 = 144;
fn try_device() -> Option<ID3D12Device> {
let mut device = None;
let result = unsafe { D3D12CreateDevice(None, D3D_FEATURE_LEVEL_11_0, &mut device) };
if let Err(error) = result {
eprintln!("skipping: D3D12CreateDevice failed on this machine: {error}");
return None;
}
device
}
fn cycle(device: &ID3D12Device, teardown: Teardown) -> usize {
let (counter, frames) = FrameCounter::new("counter");
let device = device.clone();
let pipeline = Pipeline::new("soak-d3d12", test_source("video"), move |source, ctx| {
let to_nv12 = SwScaler::new(
"to-nv12",
ffmpeg::format::Pixel::NV12,
WIDTH,
HEIGHT,
ffmpeg::software::scaling::Flags::BILINEAR,
);
let upload = D3d12Upload::new("upload", &device, WIDTH, HEIGHT)?;
let scaler = D3d12Scaler::new("scaler", &device, SCALED_WIDTH, SCALED_HEIGHT)?;
let download = D3d12Download::new("download", SCALED_WIDTH, SCALED_HEIGHT);
let branch = ctx
.branch()
.pipe(to_nv12)
.pipe(upload)
.pipe(scaler)
.queue("gpu-frames", 4)
.pipe(download)
.to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect(
"wire the D3D12 upload/scale/download pipeline — a failure after the first cycle may mean \
an earlier FFmpeg D3D12VA frames context retained its surface pool",
);
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn upload_scale_and_download_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(device) = try_device() else { return };
let mut memory = Trend::private_bytes("d3d12 scale cycle private bytes");
let mut vram = Trend::new("d3d12 scale cycle adapter memory", Unit::Bytes, {
let device = device.clone();
move || d3d12_vram_bytes(&device)
});
for index in 0..(WARMUP + iterations(15)) {
let teardown = Teardown::for_cycle(index);
let frames = cycle(&device, teardown);
if teardown == Teardown::Finish {
assert!(frames > 0, "D3D12 finish cycle {index} produced no frames");
}
if index + 1 == WARMUP {
settle();
}
if index >= WARMUP {
memory.sample();
vram.sample();
}
}
memory.assert_flat(0.25 * crate::common::MIB);
vram.assert_flat(0.5 * crate::common::MIB);
}
}
#[cfg(feature = "cuda")]
mod cuda {
use std::{sync::atomic::Ordering, thread, time::Duration};
use ffmpeg_next as ffmpeg;
use media_pp::{
color::Color,
elements::{
CudaCodec, CudaDecoder, CudaDevice, CudaDownload, CudaEncoder, CudaEncoderOptions,
CudaFrameFormat, CudaScaler, CudaScalerInterp, CudaUpload, CudaVideoCompositor,
FrameCounter, PacketCounter, SwScaler, VideoCompositorOptions, VideoLayer, VideoRect,
},
pipeline::Pipeline,
};
use crate::common::{
MIB, Trend, Unit, exclusive, gpu::nvidia_process_bytes, iterations, settle, soak_duration,
try_test_video,
};
use crate::{HEIGHT, Teardown, WARMUP, WIDTH, frame_rate, test_source};
const SCALED_WIDTH: u32 = 176;
const SCALED_HEIGHT: u32 = 144;
fn cycle(device: &CudaDevice, teardown: Teardown) -> usize {
let (counter, frames) = FrameCounter::new("counter");
let pipeline = Pipeline::new("soak-cuda", test_source("video"), move |source, ctx| {
let to_nv12 = SwScaler::new(
"to-nv12",
ffmpeg::format::Pixel::NV12,
WIDTH,
HEIGHT,
ffmpeg::software::scaling::Flags::BILINEAR,
);
let upload = CudaUpload::new("upload", device, CudaFrameFormat::Nv12, WIDTH, HEIGHT)?;
let scaler = CudaScaler::new(
"scaler",
device,
SCALED_WIDTH,
SCALED_HEIGHT,
CudaScalerInterp::Bilinear,
);
let download = CudaDownload::new(
"download",
device,
CudaFrameFormat::Nv12,
SCALED_WIDTH,
SCALED_HEIGHT,
);
let branch = ctx
.branch()
.pipe(to_nv12)
.pipe(upload)
.pipe(scaler)
.queue("gpu-frames", 4)
.pipe(download)
.to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the CUDA pipeline");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
const DECODE_QUEUE_DEPTH: usize = 4;
const DECODE_CACHE_WARMUP: usize = 50;
fn decode_cycle(device: &CudaDevice, path: &str, teardown: Teardown) -> usize {
let (source, index, parameters) = crate::open_fixture(path);
let (counter, frames) = FrameCounter::new("counter");
let pipeline = Pipeline::new("soak-cuda-decode", source, move |source, ctx| {
let decoder =
CudaDecoder::new("decoder", parameters, device, DECODE_QUEUE_DEPTH as i32)?;
let branch = ctx
.branch()
.pipe(decoder)
.queue("decoded-frames", DECODE_QUEUE_DEPTH)
.to(Box::new(counter))?;
ctx.attach(source, index, branch)?;
Ok(())
})
.expect(
"wire the CUDA decode pipeline — a failure here after the first cycle means \
an earlier decoder never released its NVDEC surfaces, which are capped at 32",
);
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
fn nvenc_options(time_base: ffmpeg::Rational) -> CudaEncoderOptions {
CudaEncoderOptions {
codec: CudaCodec::H264,
input_format: CudaFrameFormat::Nv12,
width: WIDTH,
height: HEIGHT,
time_base,
frame_rate: frame_rate(),
bit_rate: 1_000_000,
gop_size: 30,
max_b_frames: None,
}
}
fn encode_cycle(device: &CudaDevice, teardown: Teardown) -> usize {
let (counter, packets) = PacketCounter::new("counter");
let source = test_source("video");
let time_base = source.time_base();
let pipeline = Pipeline::new("soak-cuda-nvenc", source, move |source, ctx| {
let to_nv12 = SwScaler::new(
"to-nv12",
ffmpeg::format::Pixel::NV12,
WIDTH,
HEIGHT,
ffmpeg::software::scaling::Flags::BILINEAR,
);
let upload = CudaUpload::new("upload", device, CudaFrameFormat::Nv12, WIDTH, HEIGHT)?;
let encoder = CudaEncoder::new("encoder", device, nvenc_options(time_base))?;
let branch = ctx
.branch()
.pipe(to_nv12)
.pipe(upload)
.queue("gpu-frames", 4)
.pipe(encoder)
.to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect(
"wire the CUDA NVENC pipeline — a failure here after the first cycle means an \
earlier encoder never closed its NVENC session",
);
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(250));
teardown.apply(&pipeline);
packets.load(Ordering::Relaxed)
}
fn nvenc_supported(device: &CudaDevice) -> bool {
let time_base = test_source("probe").time_base();
match CudaEncoder::new("probe", device, nvenc_options(time_base)) {
Ok(_) => true,
Err(error) => {
eprintln!("skipping: no CUDA NVENC encoder on this machine ({error})");
false
}
}
}
fn decode_supported(device: &CudaDevice, path: &str) -> bool {
let (_source, _index, parameters) = crate::open_fixture(path);
match CudaDecoder::new("probe", parameters, device, 0) {
Ok(_) => true,
Err(error) => {
eprintln!("skipping: no NVDEC decoder for this fixture ({error})");
false
}
}
}
pub(crate) fn try_device() -> Option<CudaDevice> {
match CudaDevice::new() {
Ok(device) => Some(device),
Err(error) => {
eprintln!("skipping: no usable CUDA device on this machine ({error})");
None
}
}
}
fn measure_cycles(
label: &str,
post_settle_warmup: usize,
iterations: usize,
max_memory_slope: f64,
mut cycle: impl FnMut(Teardown) -> usize,
) {
let mut memory = Trend::private_bytes(format!("{label} private bytes"));
let mut gpu = match nvidia_process_bytes() {
Some(_) => Some(Trend::new(
format!("{label} driver-reported GPU memory"),
Unit::Bytes,
|| nvidia_process_bytes().unwrap_or_default(),
)),
None => {
eprintln!(
"note: this driver does not report per-process GPU memory; measuring private \
bytes only"
);
None
}
};
let measurement_start = WARMUP + post_settle_warmup;
for index in 0..(measurement_start + iterations) {
let teardown = Teardown::for_cycle(index);
let frames = cycle(teardown);
if teardown == Teardown::Finish {
assert!(
frames > 0,
"{label} {index} pushed nothing through the CUDA path before draining"
);
}
if index + 1 == WARMUP {
settle();
}
if index >= measurement_start {
memory.sample();
if let Some(gpu) = gpu.as_mut() {
gpu.sample();
}
}
}
memory.assert_flat(max_memory_slope);
if let Some(gpu) = gpu {
gpu.assert_flat(1.0 * MIB);
}
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn upload_scale_and_download_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(device) = try_device() else { return };
measure_cycles("cuda cycle", 0, iterations(25), 1.0 * MIB, |teardown| {
cycle(&device, teardown)
});
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn decode_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(path) = try_test_video() else { return };
let Some(device) = try_device() else { return };
if !decode_supported(&device, &path) {
return;
}
measure_cycles(
"cuda decode cycle",
DECODE_CACHE_WARMUP,
iterations(50),
1.0 * MIB,
|teardown| decode_cycle(&device, &path, teardown),
);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn nvenc_encode_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(device) = try_device() else { return };
if !nvenc_supported(&device) {
return;
}
measure_cycles(
"cuda nvenc cycle",
0,
iterations(15),
1.0 * MIB,
|teardown| encode_cycle(&device, teardown),
);
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn a_running_cuda_compositor_does_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(device) = try_device() else { return };
let (compositor, handle) = CudaVideoCompositor::new(
"compositor",
&device,
VideoCompositorOptions {
width: WIDTH,
height: HEIGHT,
frame_rate: frame_rate(),
background: Color::new(16, 16, 16),
},
)
.expect("build the compositor");
let layer_sink = handle
.add_source(
"moving",
VideoLayer::new(VideoRect::new(0, 0, WIDTH, HEIGHT)),
)
.expect("register the compositor input")
.sink;
let input_device = &device;
let feeder = Pipeline::new("soak-running-cuda-input", test_source("video"), {
move |source, ctx| {
let to_nv12 = SwScaler::new(
"to-nv12",
ffmpeg::format::Pixel::NV12,
WIDTH,
HEIGHT,
ffmpeg::software::scaling::Flags::BILINEAR,
);
let upload =
CudaUpload::new("upload", input_device, CudaFrameFormat::Nv12, WIDTH, HEIGHT)?;
let branch = ctx.branch().pipe(to_nv12).pipe(upload).to(layer_sink)?;
ctx.attach(source, 0, branch)?;
Ok(())
}
})
.expect("wire the compositor input pipeline");
let (counter, frames) = FrameCounter::new("counter");
let output = Pipeline::new("soak-running-cuda", compositor, |source, ctx| {
let branch = ctx.branch().queue("composited", 4).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the compositor output pipeline");
output.run().unwrap();
feeder.run().unwrap();
let duration = soak_duration(20);
let sample_interval = Duration::from_secs(1);
let mut memory = Trend::private_bytes("running cuda compositor private bytes");
let mut gpu = match nvidia_process_bytes() {
Some(_) => Some(Trend::new(
"running cuda compositor driver-reported GPU memory",
Unit::Bytes,
|| nvidia_process_bytes().unwrap_or_default(),
)),
None => {
eprintln!(
"note: this driver does not report per-process GPU memory; measuring private \
bytes only"
);
None
}
};
thread::sleep(sample_interval);
settle();
let started = frames.load(Ordering::Relaxed);
let deadline = std::time::Instant::now() + duration;
while std::time::Instant::now() < deadline {
thread::sleep(sample_interval);
memory.sample();
if let Some(gpu) = gpu.as_mut() {
gpu.sample();
}
}
let composited = frames.load(Ordering::Relaxed) - started;
feeder.finish();
output.finish();
assert!(
composited > 0,
"the compositor stopped emitting, so a flat trend proves nothing"
);
eprintln!("composited {composited} frames over {duration:?}");
memory.assert_flat(1.0 * MIB);
if let Some(gpu) = gpu {
gpu.assert_flat(1.0 * MIB);
}
}
}
#[cfg(all(target_os = "linux", feature = "pipewire-screen-capture"))]
mod pipewire {
use std::{sync::atomic::Ordering, thread, time::Duration};
use media_pp::{
elements::{
CaptureSourceKind, FrameCounter, PipeWireScreenCaptureOptions,
PipeWireScreenCaptureSource,
},
pipeline::Pipeline,
};
use crate::common::{MIB, Trend, exclusive, iterations, settle, try_restore_token};
use crate::{Teardown, WARMUP};
fn portal_sessions() -> Vec<String> {
use ashpd::zbus;
const ROOT: &str = "/org/freedesktop/portal/desktop/session";
async fn children(connection: &zbus::Connection, path: &str) -> Vec<String> {
let Ok(proxy) = zbus::fdo::IntrospectableProxy::builder(connection)
.destination("org.freedesktop.portal.Desktop")
.expect("a valid destination")
.path(path.to_owned())
.expect("a valid path")
.build()
.await
else {
return Vec::new();
};
let Ok(xml) = proxy.introspect().await else {
return Vec::new();
};
xml.split("<node name=\"")
.skip(1)
.filter_map(|rest| rest.split('"').next())
.map(|name| format!("{path}/{name}"))
.collect()
}
pollster::block_on(async {
let connection = zbus::Connection::session()
.await
.expect("a session bus to read the portal from");
let bus = zbus::fdo::DBusProxy::new(&connection)
.await
.expect("the bus's own interface");
let me = std::process::id();
let mut sessions = Vec::new();
for owner in children(&connection, ROOT).await {
let unique = format!(
":{}",
owner
.rsplit('/')
.next()
.expect("a non-empty node name")
.replace('_', ".")
);
let Ok(name) = zbus::names::BusName::try_from(unique) else {
continue;
};
if bus.get_connection_unix_process_id(name).await != Ok(me) {
continue;
}
sessions.extend(children(&connection, &owner).await);
}
sessions.sort();
sessions
})
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn dropping_a_capture_closes_its_portal_session() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(restore_token) = try_restore_token() else {
return;
};
if !capture_supported(&restore_token) {
return;
}
let before = portal_sessions();
let opened: Vec<_> = (0..3)
.map(|index| {
PipeWireScreenCaptureSource::open(format!("probe-{index}"), options(&restore_token))
.expect("open a capture with the restore token")
.0
})
.collect();
let during = portal_sessions();
assert_eq!(
during.len(),
before.len() + 3,
"three captures did not open three portal sessions; before {before:?}, during \
{during:?}"
);
drop(opened);
let after = portal_sessions();
assert_eq!(
after, before,
"a dropped capture left its portal session open — the portal still shows the screen \
as being shared. before {before:?}, after {after:?}"
);
}
fn options(restore_token: &str) -> PipeWireScreenCaptureOptions {
PipeWireScreenCaptureOptions {
fps: 30,
source_kind: CaptureSourceKind::Monitor,
include_cursor: false,
restore_token: Some(restore_token.to_owned()),
}
}
const CAPTURE_MILLIS: u64 = 400;
fn cpu_cycle(restore_token: &str, teardown: Teardown) -> usize {
let (counter, frames) = FrameCounter::new("counter");
let (source, _format, _token) =
PipeWireScreenCaptureSource::open("capture", options(restore_token)).expect(
"open the portal capture — a failure here after the first cycle means an \
earlier source never closed its portal session or PipeWire stream",
);
let pipeline = Pipeline::new("soak-pipewire-capture", source, |source, ctx| {
let branch = ctx.branch().queue("captured", 4).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the capture pipeline");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(CAPTURE_MILLIS));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
#[cfg(feature = "cuda")]
fn gpu_cycle(
device: &media_pp::elements::CudaDevice,
restore_token: &str,
teardown: Teardown,
) -> usize {
let (counter, frames) = FrameCounter::new("counter");
let (source, _format, _token) =
PipeWireScreenCaptureSource::open_gpu("capture", options(restore_token), device)
.expect(
"open the portal capture in GPU mode — a failure here after the first \
cycle means an earlier source never released its EGL images or CUDA \
surfaces",
);
let pipeline = Pipeline::new("soak-pipewire-capture-gpu", source, |source, ctx| {
let branch = ctx.branch().queue("captured", 4).to(Box::new(counter))?;
ctx.attach(source, 0, branch)?;
Ok(())
})
.expect("wire the GPU capture pipeline");
pipeline.run().unwrap();
thread::sleep(Duration::from_millis(CAPTURE_MILLIS));
teardown.apply(&pipeline);
frames.load(Ordering::Relaxed)
}
fn capture_supported(restore_token: &str) -> bool {
match PipeWireScreenCaptureSource::open("probe", options(restore_token)) {
Ok(_) => true,
Err(error) => {
eprintln!("skipping: no portal screen capture available here ({error})");
false
}
}
}
fn measure_cycles(
label: &str,
watch_gpu: bool,
iterations: usize,
max_memory_slope: f64,
mut cycle: impl FnMut(Teardown) -> usize,
) {
use crate::common::{Unit, gpu::nvidia_process_bytes};
let mut memory = Trend::private_bytes(format!("{label} private bytes"));
let mut gpu = match nvidia_process_bytes() {
Some(_) if watch_gpu => Some(Trend::new(
format!("{label} driver-reported GPU memory"),
Unit::Bytes,
|| nvidia_process_bytes().unwrap_or_default(),
)),
None if watch_gpu => {
eprintln!(
"note: this driver does not report per-process GPU memory; measuring private \
bytes only"
);
None
}
_ => None,
};
for index in 0..(WARMUP + iterations) {
let teardown = Teardown::for_cycle(index);
let frames = cycle(teardown);
if teardown == Teardown::Finish {
assert!(
frames > 0,
"{label} {index} captured nothing before draining"
);
}
if index + 1 == WARMUP {
settle();
}
if index >= WARMUP {
memory.sample();
if let Some(gpu) = gpu.as_mut() {
gpu.sample();
}
}
}
memory.assert_flat(max_memory_slope);
if let Some(gpu) = gpu {
gpu.assert_flat(1.0 * MIB);
}
}
#[test]
#[ignore = "soak test; run with --ignored"]
fn cpu_desktop_capture_cycles_do_not_grow_process_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(token) = try_restore_token() else {
return;
};
if !capture_supported(&token) {
return;
}
measure_cycles(
"pipewire capture cycle",
false,
iterations(10),
0.5 * MIB,
|teardown| cpu_cycle(&token, teardown),
);
}
#[test]
#[ignore = "soak test; run with --ignored"]
#[cfg(feature = "cuda")]
fn gpu_desktop_capture_cycles_do_not_grow_gpu_memory() {
isolate!();
let _exclusive = exclusive();
media_pp::init().expect("ffmpeg init");
let Some(token) = try_restore_token() else {
return;
};
let Some(device) = crate::cuda::try_device() else {
return;
};
if !capture_supported(&token) {
return;
}
measure_cycles(
"pipewire gpu capture cycle",
true,
iterations(10),
1.0 * MIB,
|teardown| gpu_cycle(&device, &token, teardown),
);
}
}