diff --git a/Cargo.lock b/Cargo.lock index 31b33f0..4774af5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -28,15 +28,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -77,15 +77,15 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -127,9 +127,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -140,9 +140,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -240,9 +240,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -267,9 +267,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -290,9 +290,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "serde_core" diff --git a/README.md b/README.md index 2a69647..a2354ab 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,14 @@ let mut converter = ColorConverter::new(context.clone(), config)?; // converter.convert(input_image, output_buffer)?; ``` +## Benchmarking + +Run the encode latency benchmark with: + +``` +cargo run --example encode_bench +``` + ## Examples Run the examples with: diff --git a/examples/encode_bench.rs b/examples/encode_bench.rs new file mode 100644 index 0000000..15d4e77 --- /dev/null +++ b/examples/encode_bench.rs @@ -0,0 +1,216 @@ +//! Example: Encoding Benchmarks +//! +//! Demonstrates returning of encoding statistics for benchmarking or other needs. +//! Loads raw YUV420 frames from `testdata/test_frames_1080p.yuv` in 1920 x 1080 resolution at 60 FPS. +//! Tests all supported codecs and tuning modes. + +use pixelforge::{ + Codec, EncodeBitDepth, EncodeConfig, Encoder, EncoderTuningMode, InputImage, PixelFormat, + RateControlMode, VideoContextBuilder, +}; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; + +use std::time::Duration; + +const TEST_FRAMES_PATH: &str = "testdata/test_frames_1080p.yuv"; +const WIDTH: u32 = 1920; +const HEIGHT: u32 = 1080; + +fn main() -> Result<(), Box> { + // Initialize tracing. + tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer().with_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ), + ) + .init(); + + println!("PixelForge Encode Bench Example\n"); + + // Load test frames. + let test_path = Path::new(TEST_FRAMES_PATH); + if !test_path.exists() { + eprintln!("Test frames not found at '{TEST_FRAMES_PATH}'"); + eprintln!( + "Generate with: ffmpeg -f lavfi -i testsrc=duration=5:size=1920x1080:rate=60 -pix_fmt yuv420p -f rawvideo testdata/test_frames_1080p.yuv" + ); + return Ok(()); + } + + let mut yuv_data = Vec::new(); + File::open(test_path)?.read_to_end(&mut yuv_data)?; + + let frame_size = (WIDTH * HEIGHT * 3 / 2) as usize; + let num_frames = yuv_data.len() / frame_size; + println!( + "Input: {num_frames} frames, {WIDTH}x{HEIGHT} YUV420, {} bytes\n", + yuv_data.len() + ); + + // Create video context. + let context = VideoContextBuilder::new() + .app_name("Encode Bench Example") + .enable_validation(cfg!(debug_assertions)) + .build()?; + + // Define codecs and tuning modes to test. + let codecs = [Codec::H264, Codec::H265, Codec::AV1]; + let tuning_modes = [ + EncoderTuningMode::Default, + EncoderTuningMode::HighQuality, + EncoderTuningMode::LowLatency, + EncoderTuningMode::UltraLowLatency, + EncoderTuningMode::Lossless, + ]; + + // Test each codec and tuning mode combination. + for &codec in &codecs { + if !context.supports_encode(codec) { + println!("=== {:?} not supported, skipping ===\n", codec); + continue; + } + + for &tuning_mode in &tuning_modes { + println!("=== Testing {:?} with {:?} tuning ===", codec, tuning_mode); + + match run_encode_test( + &context, + &yuv_data, + frame_size, + num_frames, + codec, + tuning_mode, + ) { + Ok(()) => println!(), + Err(e) => eprintln!("Error: {}\n", e), + } + } + } + + Ok(()) +} + +fn run_encode_test( + context: &pixelforge::VideoContext, + yuv_data: &[u8], + frame_size: usize, + num_frames: usize, + codec: Codec, + tuning_mode: EncoderTuningMode, +) -> Result<(), Box> { + // Configure encoder. + let config = match codec { + Codec::H264 => EncodeConfig::h264(WIDTH, HEIGHT), + Codec::H265 => EncodeConfig::h265(WIDTH, HEIGHT), + Codec::AV1 => EncodeConfig::av1(WIDTH, HEIGHT), + } + .with_rate_control(if matches!(tuning_mode, EncoderTuningMode::Lossless) { + RateControlMode::Cqp + } else { + RateControlMode::Cbr + }) + .with_target_bitrate(8_000_000) + .with_max_bitrate(12_000_000) + .with_frame_rate(60, 1) + .with_gop_size(120) + .with_b_frames(0) + .with_encode_content_hint(pixelforge::EncodeContentHint::Rendered) + .with_encode_usage_hint(pixelforge::EncodeUsageHint::Streaming) + .with_encoder_tuning_mode(tuning_mode); + + println!( + "Config: {:?}, bitrate={}, GOP={}, B-frames={}", + config.rate_control_mode, config.target_bitrate, config.gop_size, config.b_frame_count + ); + + // Create input image for uploading frames. + let mut input_image = InputImage::new( + context.clone(), + codec, + WIDTH, + HEIGHT, + EncodeBitDepth::Eight, + PixelFormat::Yuv420, + )?; + let mut encoder = Encoder::new(context.clone(), config)?; + let mut total_bytes = 0; + + let mut gpu_durations: Vec = Vec::new(); + let mut frame_latency_durations: Vec = Vec::new(); + let mut wall_latency_durations: Vec = Vec::new(); + + let mut pending: std::collections::VecDeque = + std::collections::VecDeque::new(); + + let mut write_packet = |packet: pixelforge::EncodedPacket| { + total_bytes += packet.data.len(); + if let Some(stats) = packet.stats { + gpu_durations.push(Duration::from_nanos(stats.gpu_time_ns)); + frame_latency_durations.push(Duration::from_nanos(stats.frame_latency_ns)); + wall_latency_durations.push(Duration::from_nanos(stats.wall_latency_ns)); + } + Ok::<(), Box>(()) + }; + + // Encode frames + for i in 0..num_frames { + let frame = &yuv_data[i * frame_size..(i + 1) * frame_size]; + + // Upload YUV420 data to the input image + input_image.upload_yuv420(frame)?; + + pending.push_back(encoder.encode(input_image.image())?); + // drain before submitting when at capacity + while pending.len() >= 2 { + let packet = pollster::block_on(pending.pop_front().unwrap())?; + write_packet(packet)?; + } + } + + // Flush remaining frames without using them for statistics + encoder.flush()?; + while let Some(_future) = pending.pop_front() {} + + let ratio = (num_frames * frame_size) as f64 / total_bytes as f64; + println!("Encoded {num_frames} frames, {total_bytes} bytes, {ratio:.1}:1 compression"); + + // Print GPU timings. + if !gpu_durations.is_empty() { + gpu_durations.sort_unstable(); + print_timing_stats("GPU", &gpu_durations); + } + + // Print frame latency timings. + if !frame_latency_durations.is_empty() { + frame_latency_durations.sort_unstable(); + print_timing_stats("Frame Latency", &frame_latency_durations); + } + + // Print wall latency timings. + if !wall_latency_durations.is_empty() { + wall_latency_durations.sort_unstable(); + print_timing_stats("Wall Latency", &wall_latency_durations); + } + + Ok(()) +} + +fn print_timing_stats(label: &str, durations: &[Duration]) { + let min = durations[0]; + let max = *durations.last().unwrap(); + let sum: Duration = durations.iter().sum(); + let avg = sum / durations.len() as u32; + let p99_index = (durations.len() as f64 * 0.99) as usize; + let p99 = durations[p99_index.min(durations.len() - 1)]; + let total = sum.as_millis(); + + println!( + "{} Encode timings: Min: {:?}, Max: {:?}, Avg: {:?}, P99: {:?}, Total: {}ms", + label, min, max, avg, p99, total + ); +} diff --git a/src/encoder/av1/init.rs b/src/encoder/av1/init.rs index f3bc71f..559b340 100644 --- a/src/encoder/av1/init.rs +++ b/src/encoder/av1/init.rs @@ -33,13 +33,24 @@ impl Av1 { let chroma_subsampling: vk::VideoChromaSubsamplingFlagsKHR = config.pixel_format.into(); let bit_depth: vk::VideoComponentBitDepthFlagsKHR = config.bit_depth.into(); + // Get encoder tuning mode, usage and content hints. + let encode_usage_hint: vk::VideoEncodeUsageFlagsKHR = config.encode_usage_hint.into(); + let encode_content_hint: vk::VideoEncodeContentFlagsKHR = config.encode_content_hint.into(); + let encoder_tuning_mode: vk::VideoEncodeTuningModeKHR = config.encoder_tuning_mode.into(); + + let mut video_encode_usage_info = vk::VideoEncodeUsageInfoKHR::default() + .video_usage_hints(encode_usage_hint) + .video_content_hints(encode_content_hint) + .tuning_mode(encoder_tuning_mode); + let mut av1_profile_info = vk::VideoEncodeAV1ProfileInfoKHR::default().std_profile(profile); let profile_info = vk::VideoProfileInfoKHR::default() .video_codec_operation(vk::VideoCodecOperationFlagsKHR::ENCODE_AV1) .chroma_subsampling(chroma_subsampling) .luma_bit_depth(bit_depth) .chroma_bit_depth(bit_depth) - .push(&mut av1_profile_info); + .push(&mut av1_profile_info) + .push(&mut video_encode_usage_info); let bitstream_buffer_size = MIN_BITSTREAM_BUFFER_SIZE .max(config.dimensions.width as usize * config.dimensions.height as usize); diff --git a/src/encoder/av1/record.rs b/src/encoder/av1/record.rs index 4b7b8b6..02db29d 100644 --- a/src/encoder/av1/record.rs +++ b/src/encoder/av1/record.rs @@ -6,7 +6,8 @@ use super::Av1; use crate::encoder::codec::{EncoderCommon, FramePlan, RateControlPlan}; use crate::encoder::pipeline::EncodeFuture; use crate::encoder::resources::{ - prepare_encode_command_buffer, record_dpb_barriers, record_post_encode_dpb_barrier, + end_timestamp, prepare_encode_command_buffer, record_dpb_barriers, + record_post_encode_dpb_barrier, reset_start_timestamp, }; use crate::error::{PixelForgeError, Result}; use ash::vk; @@ -28,6 +29,7 @@ impl Av1 { let slot = common.pipeline.current(); let command_buffer = slot.encode_command_buffer; let query_pool = slot.query_pool; + let timestamp_query_pool = slot.timestamp_query_pool; let bitstream_buffer = slot.bitstream_buffer; let bitstream_buffer_size = slot.bitstream_buffer_size; let input_image_view = slot.input_image_view; @@ -328,6 +330,9 @@ impl Av1 { .consecutive_bipredictive_frame_count(0) .temporal_layer_count(1); + // Reset and write start timestamp + reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); + let begin_coding_info = if is_first_frame { vk::VideoBeginCodingInfoKHR::default() .video_session(common.session) @@ -400,6 +405,9 @@ impl Av1 { .cmd_encode_video(command_buffer, &encode_info); device.cmd_end_query(command_buffer, query_pool, 0); + // Write end timestamp + end_timestamp(common.device(), command_buffer, timestamp_query_pool); + record_post_encode_dpb_barrier( device, command_buffer, diff --git a/src/encoder/codec.rs b/src/encoder/codec.rs index 4778a12..581c452 100644 --- a/src/encoder/codec.rs +++ b/src/encoder/codec.rs @@ -324,6 +324,8 @@ impl CodecEncoder { pts: plan.display_order, dts: plan.encode_index, header: setup.header, + timestamps: [0; 2], + now: std::time::Instant::now(), }); let future = self.codec.record_picture(&mut self.common, &plan)?; diff --git a/src/encoder/h264/init.rs b/src/encoder/h264/init.rs index 520ad45..b2efe1b 100644 --- a/src/encoder/h264/init.rs +++ b/src/encoder/h264/init.rs @@ -38,6 +38,16 @@ impl H264 { let chroma_subsampling: vk::VideoChromaSubsamplingFlagsKHR = config.pixel_format.into(); let bit_depth: vk::VideoComponentBitDepthFlagsKHR = config.bit_depth.into(); + // Get encoder tuning mode, usage and content hints. + let encode_usage_hint: vk::VideoEncodeUsageFlagsKHR = config.encode_usage_hint.into(); + let encode_content_hint: vk::VideoEncodeContentFlagsKHR = config.encode_content_hint.into(); + let encoder_tuning_mode: vk::VideoEncodeTuningModeKHR = config.encoder_tuning_mode.into(); + + let mut video_encode_usage_info = vk::VideoEncodeUsageInfoKHR::default() + .video_usage_hints(encode_usage_hint) + .video_content_hints(encode_content_hint) + .tuning_mode(encoder_tuning_mode); + let mut h264_profile_info = vk::VideoEncodeH264ProfileInfoKHR::default().std_profile_idc(profile_idc); let profile_info = vk::VideoProfileInfoKHR::default() @@ -45,7 +55,8 @@ impl H264 { .chroma_subsampling(chroma_subsampling) .luma_bit_depth(bit_depth) .chroma_bit_depth(bit_depth) - .push(&mut h264_profile_info); + .push(&mut h264_profile_info) + .push(&mut video_encode_usage_info); // The driver's preferred entropy mode is H.264-specific (some require // CAVLC for High 4:4:4 Predictive). diff --git a/src/encoder/h264/record.rs b/src/encoder/h264/record.rs index 4eef7e9..04b00df 100644 --- a/src/encoder/h264/record.rs +++ b/src/encoder/h264/record.rs @@ -5,7 +5,9 @@ use super::H264; use crate::encoder::codec::{EncoderCommon, FramePlan, RateControlPlan}; use crate::encoder::pipeline::EncodeFuture; -use crate::encoder::resources::{prepare_encode_command_buffer, record_dpb_barriers}; +use crate::encoder::resources::{ + end_timestamp, prepare_encode_command_buffer, record_dpb_barriers, reset_start_timestamp, +}; use crate::error::{PixelForgeError, Result}; use ash::vk; use ash::vk::TaggedStructure; @@ -26,6 +28,7 @@ impl H264 { let slot = common.pipeline.current(); let command_buffer = slot.encode_command_buffer; let query_pool = slot.query_pool; + let timestamp_query_pool = slot.timestamp_query_pool; let bitstream_buffer = slot.bitstream_buffer; let bitstream_buffer_size = slot.bitstream_buffer_size; let input_image_view = slot.input_image_view; @@ -465,6 +468,9 @@ impl H264 { .initial_virtual_buffer_size_in_ms(common.config.initial_virtual_buffer_size_ms); } + // Reset and write start timestamp + reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); + // For the first frame, configure rate control via the control command // after RESET rather than in begin_coding. let is_first_frame = plan.is_first_frame(); @@ -520,6 +526,9 @@ impl H264 { (common.video_encode_fn.fp().cmd_encode_video_khr)(command_buffer, &encode_info); device.cmd_end_query(command_buffer, query_pool, 0); + // Write end timestamp + end_timestamp(common.device(), command_buffer, timestamp_query_pool); + let end_info = vk::VideoEndCodingInfoKHR::default(); (common.video_queue_fn.fp().cmd_end_video_coding_khr)(command_buffer, &end_info); diff --git a/src/encoder/h265/init.rs b/src/encoder/h265/init.rs index 352e8db..9232112 100644 --- a/src/encoder/h265/init.rs +++ b/src/encoder/h265/init.rs @@ -47,6 +47,16 @@ impl H265 { let chroma_subsampling: vk::VideoChromaSubsamplingFlagsKHR = config.pixel_format.into(); let bit_depth: vk::VideoComponentBitDepthFlagsKHR = config.bit_depth.into(); + // Get encoder tuning mode, usage and content hints. + let encode_usage_hint: vk::VideoEncodeUsageFlagsKHR = config.encode_usage_hint.into(); + let encode_content_hint: vk::VideoEncodeContentFlagsKHR = config.encode_content_hint.into(); + let encoder_tuning_mode: vk::VideoEncodeTuningModeKHR = config.encoder_tuning_mode.into(); + + let mut video_encode_usage_info = vk::VideoEncodeUsageInfoKHR::default() + .video_usage_hints(encode_usage_hint) + .video_content_hints(encode_content_hint) + .tuning_mode(encoder_tuning_mode); + let mut h265_profile_info = vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(profile_idc); let profile_info = vk::VideoProfileInfoKHR::default() @@ -54,7 +64,8 @@ impl H265 { .chroma_subsampling(chroma_subsampling) .luma_bit_depth(bit_depth) .chroma_bit_depth(bit_depth) - .push(&mut h265_profile_info); + .push(&mut h265_profile_info) + .push(&mut video_encode_usage_info); // Query device capabilities with the H.265-specific capability struct // chained in (required by the driver). diff --git a/src/encoder/h265/record.rs b/src/encoder/h265/record.rs index 28d7deb..70d50c7 100644 --- a/src/encoder/h265/record.rs +++ b/src/encoder/h265/record.rs @@ -6,7 +6,8 @@ use super::H265; use crate::encoder::codec::{EncoderCommon, FramePlan, RateControlPlan}; use crate::encoder::pipeline::EncodeFuture; use crate::encoder::resources::{ - prepare_encode_command_buffer, record_dpb_barriers, record_post_encode_dpb_barrier, + end_timestamp, prepare_encode_command_buffer, record_dpb_barriers, + record_post_encode_dpb_barrier, reset_start_timestamp, }; use crate::error::{PixelForgeError, Result}; use ash::vk; @@ -27,6 +28,7 @@ impl H265 { let slot = common.pipeline.current(); let command_buffer = slot.encode_command_buffer; let query_pool = slot.query_pool; + let timestamp_query_pool = slot.timestamp_query_pool; let bitstream_buffer = slot.bitstream_buffer; let bitstream_buffer_size = slot.bitstream_buffer_size; let input_image_view = slot.input_image_view; @@ -437,6 +439,9 @@ impl H265 { .initial_virtual_buffer_size_in_ms(common.config.initial_virtual_buffer_size_ms); } + // Reset and write start timestamp + reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); + let is_first_frame = plan.is_first_frame(); let begin_coding_info = if is_first_frame { vk::VideoBeginCodingInfoKHR::default() @@ -509,6 +514,9 @@ impl H265 { (common.video_encode_fn.fp().cmd_encode_video_khr)(command_buffer, &encode_info); device.cmd_end_query(command_buffer, query_pool, 0); + // Write end timestamp + end_timestamp(common.device(), command_buffer, timestamp_query_pool); + record_post_encode_dpb_barrier( device, command_buffer, diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index fbb1a02..09beb7e 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -122,6 +122,89 @@ pub enum RateControlMode { Vbr, } +/// Encode usage hints. +/// Allows encoder to potentially make smarter choices with appropriate usage hint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EncodeUsageHint { + /// Default usage - no specific usage hint given to encoder. + #[default] + Default, + /// Transcoding usage - hint that encoding will be done in conjunction with decoding. + Transcoding, + /// Streaming usage - hint that the output will be sent over network. + Streaming, + /// Recording usage - hint that the output will be used for offline consumption. + Recording, + /// Conferencing usage - hint that the output will be used for video conferencing. + Conferencing, +} + +impl From for vk::VideoEncodeUsageFlagsKHR { + fn from(hint: EncodeUsageHint) -> Self { + match hint { + EncodeUsageHint::Default => vk::VideoEncodeUsageFlagsKHR::DEFAULT, + EncodeUsageHint::Transcoding => vk::VideoEncodeUsageFlagsKHR::TRANSCODING, + EncodeUsageHint::Streaming => vk::VideoEncodeUsageFlagsKHR::STREAMING, + EncodeUsageHint::Recording => vk::VideoEncodeUsageFlagsKHR::RECORDING, + EncodeUsageHint::Conferencing => vk::VideoEncodeUsageFlagsKHR::CONFERENCING, + } + } +} + +/// Encode content hints. +/// Allows encoder to potentially make smarter choices with appropriate content hint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EncodeContentHint { + /// Default content - no specific content hint given to encoder. + #[default] + Default, + /// Camera content - hint that the content is from a camera. + Camera, + /// Desktop content - hint that the content is from desktop. + Desktop, + /// Rendered content - hint that the content is rendered (i.e. game). + Rendered, +} + +impl From for vk::VideoEncodeContentFlagsKHR { + fn from(hint: EncodeContentHint) -> Self { + match hint { + EncodeContentHint::Default => vk::VideoEncodeContentFlagsKHR::DEFAULT, + EncodeContentHint::Camera => vk::VideoEncodeContentFlagsKHR::CAMERA, + EncodeContentHint::Desktop => vk::VideoEncodeContentFlagsKHR::DESKTOP, + EncodeContentHint::Rendered => vk::VideoEncodeContentFlagsKHR::RENDERED, + } + } +} + +/// Encoder tuning modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EncoderTuningMode { + /// Default mode - encoder specific default tuning. + #[default] + Default, + /// High-quality mode - focus on quality over encoding speed. + HighQuality, + /// Low-latency mode - focus on encoding speed over quality. + LowLatency, + /// Ultra-low-latency mode - focus on highest encoding speed with a hit to quality. + UltraLowLatency, + /// Lossless mode - tune encoder for lossless output. + Lossless, +} + +impl From for vk::VideoEncodeTuningModeKHR { + fn from(mode: EncoderTuningMode) -> Self { + match mode { + EncoderTuningMode::Default => vk::VideoEncodeTuningModeKHR::DEFAULT, + EncoderTuningMode::HighQuality => vk::VideoEncodeTuningModeKHR::HIGH_QUALITY, + EncoderTuningMode::LowLatency => vk::VideoEncodeTuningModeKHR::LOW_LATENCY, + EncoderTuningMode::UltraLowLatency => vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY, + EncoderTuningMode::Lossless => vk::VideoEncodeTuningModeKHR::LOSSLESS, + } + } +} + /// Frame types in encoded stream. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FrameType { @@ -226,6 +309,12 @@ pub struct EncodeConfig { /// Color description for VUI signaling. /// Defaults to BT.709 (full-range) when `None`. pub color_description: Option, + /// Usage hint for encoding. + pub encode_usage_hint: EncodeUsageHint, + /// Content hint for encoding. + pub encode_content_hint: EncodeContentHint, + /// Encoder tuning mode. + pub encoder_tuning_mode: EncoderTuningMode, } impl EncodeConfig { @@ -251,6 +340,9 @@ impl EncodeConfig { virtual_buffer_size_ms: 1000, initial_virtual_buffer_size_ms: 1000, color_description: None, + encode_usage_hint: EncodeUsageHint::Default, + encode_content_hint: EncodeContentHint::Default, + encoder_tuning_mode: EncoderTuningMode::Default, } } @@ -276,6 +368,9 @@ impl EncodeConfig { virtual_buffer_size_ms: 1000, initial_virtual_buffer_size_ms: 1000, color_description: None, + encode_usage_hint: EncodeUsageHint::Default, + encode_content_hint: EncodeContentHint::Default, + encoder_tuning_mode: EncoderTuningMode::Default, } } @@ -301,6 +396,9 @@ impl EncodeConfig { virtual_buffer_size_ms: 1000, initial_virtual_buffer_size_ms: 1000, color_description: None, + encode_usage_hint: EncodeUsageHint::Default, + encode_content_hint: EncodeContentHint::Default, + encoder_tuning_mode: EncoderTuningMode::Default, } } @@ -385,10 +483,39 @@ impl EncodeConfig { self.color_description = Some(desc); self } + + /// Set the usage hint for encoding. + pub fn with_encode_usage_hint(mut self, hint: EncodeUsageHint) -> Self { + self.encode_usage_hint = hint; + self + } + + /// Set the content hint for encoding. + pub fn with_encode_content_hint(mut self, hint: EncodeContentHint) -> Self { + self.encode_content_hint = hint; + self + } + + /// Set the encoder tuning mode. + pub fn with_encoder_tuning_mode(mut self, mode: EncoderTuningMode) -> Self { + self.encoder_tuning_mode = mode; + self + } } pub use pipeline::EncodeFuture; +/// Statistic about the encoded video packet. +#[derive(Debug, Clone)] +pub struct EncodedPacketStats { + /// GPU encode time in nanoseconds + pub gpu_time_ns: u64, + /// CPU wall time in nanoseconds (submission + fence wait + readback) + pub frame_latency_ns: u64, + /// Wall latency in nanoseconds (time between submit and bitstream ready) + pub wall_latency_ns: u64, +} + /// Encoded video packet. #[derive(Debug, Clone)] pub struct EncodedPacket { @@ -402,6 +529,8 @@ pub struct EncodedPacket { pub pts: u64, /// Decode timestamp. pub dts: u64, + /// Optional stats about the packet + pub stats: Option, } /// The codec-erased operations every [`codec::CodecEncoder`] exposes. @@ -834,6 +963,7 @@ mod tests { is_key_frame: true, pts: 0, dts: 0, + stats: None, }; assert!(packet.is_key_frame); diff --git a/src/encoder/pipeline.rs b/src/encoder/pipeline.rs index 33043ed..233506e 100644 --- a/src/encoder/pipeline.rs +++ b/src/encoder/pipeline.rs @@ -40,8 +40,9 @@ use futures_channel::oneshot; use crate::encoder::resources::{ ClearImageParams, clear_input_image, create_bitstream_buffer, - create_encode_feedback_query_pool, create_image, create_timeline_semaphore, - map_bitstream_buffer, submit_encode_only, wait_and_read_bitstream, + create_encode_feedback_query_pool, create_encode_timestamp_query_pool, create_image, + create_timeline_semaphore, map_bitstream_buffer, query_timestamp_diff, submit_encode_only, + wait_and_read_bitstream, }; use crate::encoder::{BitDepth, EncodedPacket, FrameType, PixelFormat}; use crate::error::{PixelForgeError, Result}; @@ -98,6 +99,8 @@ pub(crate) struct SlotPacketMetadata { /// Codec header (SPS/PPS, VPS/SPS/PPS, or AV1 sequence header) to prepend. /// `Some` only for frames that carry one (e.g. the IDR/key frame). pub header: Option>, + pub timestamps: [u64; 2], + pub now: std::time::Instant, } /// A raw pointer wrapper asserting `Send` so the persistently-mapped bitstream @@ -117,6 +120,10 @@ struct WorkItem { query_pool: vk::QueryPool, bitstream_ptr: SendPtr, metadata: SlotPacketMetadata, + /// Timestamping resources + timestamp_period: f32, + timestamp_query_pool: vk::QueryPool, + submit_time: std::time::Instant, /// Resolves this frame's [`EncodeFuture`] once the bitstream is read back. result_tx: oneshot::Sender>, } @@ -182,6 +189,9 @@ pub(crate) struct EncodeSlot { pub encode_fence: vk::Fence, pub query_pool: vk::QueryPool, + pub timestamp_period: f32, + pub timestamp_query_pool: vk::QueryPool, + /// Packet metadata recorded before submit, moved to the completion thread /// with the work item. pub pending_metadata: Option, @@ -290,6 +300,15 @@ impl EncodePipeline { let mut profile = *config.profile_info; let query_pool = create_encode_feedback_query_pool(context, &mut profile)?; + let timestamp_query_pool = create_encode_timestamp_query_pool(context)?; + let timestamp_period = unsafe { + context + .instance() + .get_physical_device_properties(context.physical_device()) + .limits + .timestamp_period + }; + slots.push(EncodeSlot { input_image, input_image_memory, @@ -302,6 +321,8 @@ impl EncodePipeline { encode_command_buffer, encode_fence, query_pool, + timestamp_period, + timestamp_query_pool, pending_metadata: None, }); } @@ -384,7 +405,15 @@ impl EncodePipeline { // Capture the Copy handles + metadata, releasing the slot borrow before // touching the cross-thread channels. - let (command_buffer, fence, query_pool, bitstream_ptr, metadata) = { + let ( + command_buffer, + fence, + query_pool, + bitstream_ptr, + timestamp_period, + timestamp_query_pool, + metadata, + ) = { let slot = &mut self.slots[slot_index]; let metadata = slot.pending_metadata.take().ok_or_else(|| { PixelForgeError::CommandBuffer( @@ -396,6 +425,8 @@ impl EncodePipeline { slot.encode_fence, slot.query_pool, slot.bitstream_buffer_ptr as *const u8, + slot.timestamp_period, + slot.timestamp_query_pool, metadata, ) }; @@ -410,6 +441,7 @@ impl EncodePipeline { Some((self.timeline, signal_value)), )?; } + let submit_time = std::time::Instant::now(); self.last_value = signal_value; self.next_value = signal_value + 1; @@ -424,6 +456,9 @@ impl EncodePipeline { fence, query_pool, bitstream_ptr: SendPtr(bitstream_ptr), + timestamp_period, + timestamp_query_pool, + submit_time, metadata, result_tx, }; @@ -479,6 +514,7 @@ impl EncodePipeline { slot.bitstream_buffer_ptr = std::ptr::null_mut(); } unsafe { + device.destroy_query_pool(slot.timestamp_query_pool, None); device.destroy_query_pool(slot.query_pool, None); device.destroy_fence(slot.encode_fence, None); device.destroy_buffer(slot.bitstream_buffer, None); @@ -514,6 +550,25 @@ fn run_completion_thread( &mut data, ) }; + let bitstream_ready_time = std::time::Instant::now(); + + let mut stats: Option = None; + if let Some(gpu_encode_ns) = unsafe { + query_timestamp_diff( + &device, + work.timestamp_query_pool, + work.metadata.timestamps, + work.timestamp_period, + ) + } { + stats = Some(super::EncodedPacketStats { + gpu_time_ns: gpu_encode_ns, + frame_latency_ns: work.metadata.now.elapsed().as_nanos() as u64, + wall_latency_ns: bitstream_ready_time + .duration_since(work.submit_time) + .as_nanos() as u64, + }); + } let packet = result.map(|()| EncodedPacket { data, @@ -521,6 +576,7 @@ fn run_completion_thread( is_key_frame: work.metadata.is_key_frame, pts: work.metadata.pts, dts: work.metadata.dts, + stats, }); // Resolve the future *before* freeing the slot. This ordering means that diff --git a/src/encoder/resources.rs b/src/encoder/resources.rs index 41183b8..bc2fe5e 100644 --- a/src/encoder/resources.rs +++ b/src/encoder/resources.rs @@ -273,6 +273,19 @@ pub(crate) fn create_encode_feedback_query_pool( .map_err(|e| PixelForgeError::QueryPool(e.to_string())) } +pub(crate) fn create_encode_timestamp_query_pool(context: &VideoContext) -> Result { + let timestamp_query_pool_create_info = vk::QueryPoolCreateInfo::default() + .query_type(vk::QueryType::TIMESTAMP) + .query_count(2); // start and end + + unsafe { + context + .device() + .create_query_pool(×tamp_query_pool_create_info, None) + } + .map_err(|e| PixelForgeError::QueryPool(e.to_string())) +} + /// Create an image for video encoding (input or DPB). /// /// This creates a VkImage suitable for use with a video encoder. @@ -1583,6 +1596,65 @@ pub(crate) fn get_encoded_session_params( } } +/// Resets the given query pool and writes starting timestamp command. +pub(crate) fn reset_start_timestamp( + device: &ash::Device, + command_buffer: vk::CommandBuffer, + query_pool: vk::QueryPool, +) { + unsafe { + device.cmd_reset_query_pool(command_buffer, query_pool, 0, 2); + device.cmd_write_timestamp( + command_buffer, + vk::PipelineStageFlags::TOP_OF_PIPE, + query_pool, + 0, // query index 0 = start + ); + } +} + +/// Writes ending timestamp command to the given query pool. +pub(crate) fn end_timestamp( + device: &ash::Device, + command_buffer: vk::CommandBuffer, + query_pool: vk::QueryPool, +) { + unsafe { + device.cmd_write_timestamp( + command_buffer, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + query_pool, + 1, // query index 1 = end + ); + } +} + +/// Queries the given query pool for recorded timestamps, returning their difference. +/// +/// # Safety +/// This must only be called if both `reset_start_timestamp` and `end_timestamp` +/// were previously written and executed for the given query pool. +pub(crate) unsafe fn query_timestamp_diff( + device: &ash::Device, + query_pool: vk::QueryPool, + mut timestamps: [u64; 2], + timestamp_period: f32, +) -> Option { + let mut encode_time_ns: Option = None; + let result = unsafe { + device.get_query_pool_results( + query_pool, + 0, // first_query + &mut timestamps, // data slice (length 2) + vk::QueryResultFlags::WAIT | vk::QueryResultFlags::TYPE_64, + ) + }; + if result.is_ok() { + encode_time_ns = Some(((timestamps[1] - timestamps[0]) as f32 * timestamp_period) as u64); + } + encode_time_ns +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index dcabf40..cbb8269 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -141,6 +141,14 @@ //! # } //! ``` //! +//! # Benchmarking +//! +//! Run the encode latency benchmark with: +//! +//! ```text +//! cargo run --example encode_bench +//! ``` +//! //! # Examples //! //! Run the examples with: @@ -199,8 +207,8 @@ pub use converter::{ColorConverter, ColorConverterConfig, ColorSpace, InputForma pub use encoder::{ BitDepth as EncodeBitDepth, Codec, ColorDescription, DEFAULT_FRAME_RATE, DEFAULT_GOP_SIZE, DEFAULT_H264_QP, DEFAULT_H265_QP, DEFAULT_MAX_BITRATE, DEFAULT_MAX_REFERENCE_FRAMES, - DEFAULT_TARGET_BITRATE, EncodeConfig, EncodeFuture, EncodedPacket, Encoder, FrameType, - PixelFormat, RateControlMode, + DEFAULT_TARGET_BITRATE, EncodeConfig, EncodeContentHint, EncodeFuture, EncodeUsageHint, + EncodedPacket, Encoder, EncoderTuningMode, FrameType, PixelFormat, RateControlMode, }; pub use error::PixelForgeError; pub use image::InputImage;