From 10762126fb0ba0286fc0c849aa2f9c886441404e Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Thu, 11 Jun 2026 23:11:37 +0300 Subject: [PATCH 01/12] feat: Add encode hints and tuning modes --- src/encoder/av1/init.rs | 13 ++++- src/encoder/h264/init.rs | 13 ++++- src/encoder/h265/init.rs | 13 ++++- src/encoder/mod.rs | 116 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 +- 5 files changed, 154 insertions(+), 5 deletions(-) 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/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/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/mod.rs b/src/encoder/mod.rs index fbb1a02..1ffc0e1 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,6 +483,24 @@ 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; diff --git a/src/lib.rs b/src/lib.rs index dcabf40..328598a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -199,8 +199,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; From c526dc8d376c7b3e60636c6fc57a7e6f5751778e Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 12 Jun 2026 06:43:12 +0300 Subject: [PATCH 02/12] feat: Add encode timing stats --- Cargo.lock | 44 +++++++++++++-------------- src/encoder/av1/record.rs | 11 ++++++- src/encoder/codec.rs | 2 ++ src/encoder/h264/record.rs | 12 +++++++- src/encoder/h265/record.rs | 11 ++++++- src/encoder/mod.rs | 12 ++++++++ src/encoder/pipeline.rs | 54 +++++++++++++++++++++++++++++++-- src/encoder/resources.rs | 61 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 179 insertions(+), 28 deletions(-) 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/src/encoder/av1/record.rs b/src/encoder/av1/record.rs index 4b7b8b6..0e835aa 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; @@ -45,6 +47,10 @@ impl Av1 { unsafe { prepare_encode_command_buffer(common.device(), command_buffer, query_pool)?; } + // Reset and write start timestamp + unsafe { + reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); + } let ref_dpb_slots: Vec = self.references.iter().map(|r| r.dpb_slot).collect(); unsafe { record_dpb_barriers( @@ -400,6 +406,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/record.rs b/src/encoder/h264/record.rs index 4eef7e9..bd12e16 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; @@ -81,6 +84,10 @@ impl H264 { unsafe { prepare_encode_command_buffer(common.device(), command_buffer, query_pool)?; } + // Reset and write start timestamp + unsafe { + reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); + } let ref_dpb_slots: Vec = self.l0_references.iter().map(|r| r.dpb_slot).collect(); unsafe { record_dpb_barriers( @@ -520,6 +527,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/record.rs b/src/encoder/h265/record.rs index 28d7deb..33cf5d2 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; @@ -34,6 +36,10 @@ impl H265 { unsafe { prepare_encode_command_buffer(common.device(), command_buffer, query_pool)?; } + // Reset and write start timestamp + unsafe { + reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); + } let ref_dpb_slots: Vec = self.l0_references.iter().map(|r| r.dpb_slot).collect(); unsafe { record_dpb_barriers( @@ -509,6 +515,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 1ffc0e1..6cdc480 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -505,6 +505,15 @@ impl EncodeConfig { 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 cpu_wall_time_ns: u64, +} + /// Encoded video packet. #[derive(Debug, Clone)] pub struct EncodedPacket { @@ -518,6 +527,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. @@ -950,6 +961,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..42e3219 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,9 @@ struct WorkItem { query_pool: vk::QueryPool, bitstream_ptr: SendPtr, metadata: SlotPacketMetadata, + /// Timestamping resources + timestamp_period: f32, + timestamp_query_pool: vk::QueryPool, /// Resolves this frame's [`EncodeFuture`] once the bitstream is read back. result_tx: oneshot::Sender>, } @@ -182,6 +188,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 +299,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 +320,8 @@ impl EncodePipeline { encode_command_buffer, encode_fence, query_pool, + timestamp_period, + timestamp_query_pool, pending_metadata: None, }); } @@ -384,7 +404,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 +424,8 @@ impl EncodePipeline { slot.encode_fence, slot.query_pool, slot.bitstream_buffer_ptr as *const u8, + slot.timestamp_period, + slot.timestamp_query_pool, metadata, ) }; @@ -424,6 +454,8 @@ impl EncodePipeline { fence, query_pool, bitstream_ptr: SendPtr(bitstream_ptr), + timestamp_period, + timestamp_query_pool, metadata, result_tx, }; @@ -515,12 +547,28 @@ fn run_completion_thread( ) }; + 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, + cpu_wall_time_ns: work.metadata.now.elapsed().as_nanos() as u64, + }); + } + let packet = result.map(|()| EncodedPacket { data, frame_type: work.metadata.frame_type, 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..185862f 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,54 @@ pub(crate) fn get_encoded_session_params( } } +/// Resets and writes first timestamp command +pub(crate) unsafe fn reset_start_timestamp( + device: &ash::Device, + command_buffer: vk::CommandBuffer, + query_pool: vk::QueryPool, +) { + 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 + ); +} + +pub(crate) unsafe fn end_timestamp( + device: &ash::Device, + command_buffer: vk::CommandBuffer, + query_pool: vk::QueryPool, +) { + device.cmd_write_timestamp( + command_buffer, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + query_pool, + 1, // query index 1 = end + ); +} + +/// Queries and possibly returns the difference between reset_start_timestamp and end_timestamp calls +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 = 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); + } + return encode_time_ns; +} + #[cfg(test)] mod tests { use super::*; From 2df62665434e6da268bf420cfa9397311863e48c Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Mon, 15 Jun 2026 21:03:04 +0300 Subject: [PATCH 03/12] feat: Add bench example and fix encode usage for h26x codecs. --- examples/encode_bench.rs | 197 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 examples/encode_bench.rs diff --git a/examples/encode_bench.rs b/examples/encode_bench.rs new file mode 100644 index 0000000..3a9a406 --- /dev/null +++ b/examples/encode_bench.rs @@ -0,0 +1,197 @@ +//! 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::SubscriberExt, util::SubscriberInitExt, Layer}; + +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)) + .require_encode(Codec::H264) + .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 cpu_durations: Vec = Vec::new(); + + // 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)?; + + // Encode the image. + for packet in encoder.encode(input_image.image())? { + total_bytes += packet.data.len(); + if let Some(stats) = packet.encoding_stats { + gpu_durations.push(Duration::from_nanos(stats.gpu_time_ns)); + cpu_durations.push(Duration::from_nanos(stats.cpu_wall_time_ns)); + } + } + } + + // Flush remaining frames. + for packet in encoder.flush()? { + total_bytes += packet.data.len(); + } + + 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 CPU timings. + if !cpu_durations.is_empty() { + cpu_durations.sort_unstable(); + print_timing_stats("CPU", &cpu_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)]; + + println!( + "{} Encode timings: Min: {:?}, Max: {:?}, Avg: {:?}, P99: {:?}", + label, min, max, avg, p99 + ); +} From 7c00f07aeb63185a294a8992303d33d4679263cb Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Tue, 16 Jun 2026 17:04:07 +0300 Subject: [PATCH 04/12] fix: Update bench for async --- examples/encode_bench.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/encode_bench.rs b/examples/encode_bench.rs index 3a9a406..464f921 100644 --- a/examples/encode_bench.rs +++ b/examples/encode_bench.rs @@ -149,10 +149,11 @@ fn run_encode_test( // Upload YUV420 data to the input image. input_image.upload_yuv420(frame)?; - // Encode the image. - for packet in encoder.encode(input_image.image())? { + // Encode the image (async); drain any packets that are ready. + encoder.encode(input_image.image())?; + while let Some(packet) = encoder.poll_packet()? { total_bytes += packet.data.len(); - if let Some(stats) = packet.encoding_stats { + if let Some(stats) = packet.stats { gpu_durations.push(Duration::from_nanos(stats.gpu_time_ns)); cpu_durations.push(Duration::from_nanos(stats.cpu_wall_time_ns)); } From 45ac1c6689c3c7892b679e0923d4dbad4cf3802a Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Tue, 16 Jun 2026 17:07:47 +0300 Subject: [PATCH 05/12] fix: add missing timestamp query pool destroy --- src/encoder/pipeline.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/encoder/pipeline.rs b/src/encoder/pipeline.rs index 42e3219..b9e91a4 100644 --- a/src/encoder/pipeline.rs +++ b/src/encoder/pipeline.rs @@ -511,6 +511,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); From aa86e36574cd2d0280a6185ca22494b426d6ef9c Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Tue, 16 Jun 2026 17:09:39 +0300 Subject: [PATCH 06/12] fix: Make clippy happy --- src/encoder/resources.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/encoder/resources.rs b/src/encoder/resources.rs index 185862f..da97b00 100644 --- a/src/encoder/resources.rs +++ b/src/encoder/resources.rs @@ -1641,7 +1641,7 @@ pub(crate) unsafe fn query_timestamp_diff( if result.is_ok() { encode_time_ns = Some(((timestamps[1] - timestamps[0]) as f32 * timestamp_period) as u64); } - return encode_time_ns; + encode_time_ns } #[cfg(test)] From 00c251442e4205f0e4d5ae4302e3e7143764d55f Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 26 Jun 2026 15:13:49 +0300 Subject: [PATCH 07/12] fix: bench example and warnings --- examples/encode_bench.rs | 43 +++++++++++++++++++++++++-------------- src/encoder/resources.rs | 44 +++++++++++++++++++++++----------------- 2 files changed, 53 insertions(+), 34 deletions(-) diff --git a/examples/encode_bench.rs b/examples/encode_bench.rs index 464f921..9fdfa2c 100644 --- a/examples/encode_bench.rs +++ b/examples/encode_bench.rs @@ -11,7 +11,7 @@ use pixelforge::{ use std::fs::File; use std::io::Read; use std::path::Path; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; +use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; use std::time::Duration; @@ -36,7 +36,9 @@ fn main() -> Result<(), Box> { 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"); + 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(()); } @@ -142,27 +144,38 @@ fn run_encode_test( let mut gpu_durations: Vec = Vec::new(); let mut cpu_durations: Vec = Vec::new(); - // Encode frames. + 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)); + cpu_durations.push(Duration::from_nanos(stats.cpu_wall_time_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. + // Upload YUV420 data to the input image input_image.upload_yuv420(frame)?; - // Encode the image (async); drain any packets that are ready. - encoder.encode(input_image.image())?; - while let Some(packet) = encoder.poll_packet()? { - total_bytes += packet.data.len(); - if let Some(stats) = packet.stats { - gpu_durations.push(Duration::from_nanos(stats.gpu_time_ns)); - cpu_durations.push(Duration::from_nanos(stats.cpu_wall_time_ns)); - } + // Submit the frame (async) and keep the pipeline at most ~2 deep + pending.push_back(encoder.encode(input_image.image())?); + while pending.len() > 2 { + let packet = pollster::block_on(pending.pop_front().unwrap())?; + write_packet(packet)?; } } - // Flush remaining frames. - for packet in encoder.flush()? { - total_bytes += packet.data.len(); + // Flush remaining frames + encoder.flush()?; + while let Some(future) = pending.pop_front() { + let packet = pollster::block_on(future)?; + write_packet(packet)?; } let ratio = (num_frames * frame_size) as f64 / total_bytes as f64; diff --git a/src/encoder/resources.rs b/src/encoder/resources.rs index da97b00..cbe9e05 100644 --- a/src/encoder/resources.rs +++ b/src/encoder/resources.rs @@ -1602,13 +1602,15 @@ pub(crate) unsafe fn reset_start_timestamp( command_buffer: vk::CommandBuffer, query_pool: vk::QueryPool, ) { - 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 - ); + 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 + ); + } } pub(crate) unsafe fn end_timestamp( @@ -1616,12 +1618,14 @@ pub(crate) unsafe fn end_timestamp( command_buffer: vk::CommandBuffer, query_pool: vk::QueryPool, ) { - device.cmd_write_timestamp( - command_buffer, - vk::PipelineStageFlags::BOTTOM_OF_PIPE, - query_pool, - 1, // query index 1 = end - ); + unsafe { + device.cmd_write_timestamp( + command_buffer, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + query_pool, + 1, // query index 1 = end + ); + } } /// Queries and possibly returns the difference between reset_start_timestamp and end_timestamp calls @@ -1632,12 +1636,14 @@ pub(crate) unsafe fn query_timestamp_diff( timestamp_period: f32, ) -> Option { let mut encode_time_ns: Option = None; - let result = device.get_query_pool_results( - query_pool, - 0, // first_query - &mut timestamps, // data slice (length 2) - vk::QueryResultFlags::WAIT | vk::QueryResultFlags::TYPE_64, - ); + 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); } From b8a25d59bf93f2d0e2f422e30327671cd48f08de Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 26 Jun 2026 15:25:01 +0300 Subject: [PATCH 08/12] feat: add submission wall latency timing --- examples/encode_bench.rs | 10 +++++++++- src/encoder/mod.rs | 4 +++- src/encoder/pipeline.rs | 9 ++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/examples/encode_bench.rs b/examples/encode_bench.rs index 9fdfa2c..9358189 100644 --- a/examples/encode_bench.rs +++ b/examples/encode_bench.rs @@ -143,6 +143,7 @@ fn run_encode_test( let mut gpu_durations: Vec = Vec::new(); let mut cpu_durations: Vec = Vec::new(); + let mut wall_latency_durations: Vec = Vec::new(); let mut pending: std::collections::VecDeque = std::collections::VecDeque::new(); @@ -151,7 +152,8 @@ fn run_encode_test( total_bytes += packet.data.len(); if let Some(stats) = packet.stats { gpu_durations.push(Duration::from_nanos(stats.gpu_time_ns)); - cpu_durations.push(Duration::from_nanos(stats.cpu_wall_time_ns)); + cpu_durations.push(Duration::from_nanos(stats.cpu_time_ns)); + wall_latency_durations.push(Duration::from_nanos(stats.wall_latency_ns)); } Ok::<(), Box>(()) }; @@ -193,6 +195,12 @@ fn run_encode_test( print_timing_stats("CPU", &cpu_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(()) } diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index 6cdc480..ba80852 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -511,7 +511,9 @@ pub struct EncodedPacketStats { /// GPU encode time in nanoseconds pub gpu_time_ns: u64, /// CPU wall time in nanoseconds (submission + fence wait + readback) - pub cpu_wall_time_ns: u64, + pub cpu_time_ns: u64, + /// Wall latency in nanoseconds (time between submit and bitstream ready) + pub wall_latency_ns: u64, } /// Encoded video packet. diff --git a/src/encoder/pipeline.rs b/src/encoder/pipeline.rs index b9e91a4..e97f363 100644 --- a/src/encoder/pipeline.rs +++ b/src/encoder/pipeline.rs @@ -123,6 +123,7 @@ struct WorkItem { /// 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>, } @@ -443,6 +444,7 @@ impl EncodePipeline { self.last_value = signal_value; self.next_value = signal_value + 1; + let submit_time = std::time::Instant::now(); // Mark busy *before* handing the work off, so the completion thread can // never clear the flag before it is set. @@ -456,6 +458,7 @@ impl EncodePipeline { bitstream_ptr: SendPtr(bitstream_ptr), timestamp_period, timestamp_query_pool, + submit_time, metadata, result_tx, }; @@ -547,6 +550,7 @@ 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 { @@ -559,7 +563,10 @@ fn run_completion_thread( } { stats = Some(super::EncodedPacketStats { gpu_time_ns: gpu_encode_ns, - cpu_wall_time_ns: work.metadata.now.elapsed().as_nanos() as u64, + cpu_time_ns: work.metadata.now.elapsed().as_nanos() as u64, + wall_latency_ns: bitstream_ready_time + .duration_since(work.submit_time) + .as_nanos() as u64, }); } From ad7399431de43b0d7f5491cc69236a95ee58c5c5 Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 26 Jun 2026 15:37:20 +0300 Subject: [PATCH 09/12] fix: don't use flushed frames for benching --- examples/encode_bench.rs | 12 ++++-------- src/encoder/pipeline.rs | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/encode_bench.rs b/examples/encode_bench.rs index 9358189..c83e646 100644 --- a/examples/encode_bench.rs +++ b/examples/encode_bench.rs @@ -56,7 +56,6 @@ fn main() -> Result<(), Box> { let context = VideoContextBuilder::new() .app_name("Encode Bench Example") .enable_validation(cfg!(debug_assertions)) - .require_encode(Codec::H264) .build()?; // Define codecs and tuning modes to test. @@ -165,20 +164,17 @@ fn run_encode_test( // Upload YUV420 data to the input image input_image.upload_yuv420(frame)?; - // Submit the frame (async) and keep the pipeline at most ~2 deep pending.push_back(encoder.encode(input_image.image())?); - while pending.len() > 2 { + // 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 + // Flush remaining frames without using them for statistics encoder.flush()?; - while let Some(future) = pending.pop_front() { - let packet = pollster::block_on(future)?; - write_packet(packet)?; - } + 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"); diff --git a/src/encoder/pipeline.rs b/src/encoder/pipeline.rs index e97f363..1a72f31 100644 --- a/src/encoder/pipeline.rs +++ b/src/encoder/pipeline.rs @@ -441,10 +441,10 @@ 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; - let submit_time = std::time::Instant::now(); // Mark busy *before* handing the work off, so the completion thread can // never clear the flag before it is set. From 7908395116d54f4f6545d6490d7b95f0e3e07ea1 Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Mon, 29 Jun 2026 18:05:00 +0300 Subject: [PATCH 10/12] fix: Resolve comments and issues --- README.md | 36 ++++++++++++++++++++++-------------- examples/encode_bench.rs | 17 +++++++++-------- src/encoder/av1/record.rs | 9 +++++---- src/encoder/h264/record.rs | 9 +++++---- src/encoder/h265/record.rs | 9 +++++---- src/encoder/mod.rs | 2 +- src/encoder/pipeline.rs | 2 +- src/lib.rs | 8 ++++++++ 8 files changed, 56 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 2a69647..9d10d9d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Vulkan-based video encoding library for Rust, supporting H.264, H.265, and AV1 codecs. -## Features +# Features - **Hardware-accelerated** video encoding using Vulkan Video extensions. - **Multiple codec support**: H.264/AVC, H.265/HEVC, AV1. @@ -20,7 +20,7 @@ A Vulkan-based video encoding library for Rust, supporting H.264, H.265, and AV1 > **Note**: B-frame support is not yet implemented. Setting `b_frame_count > 0` will panic. -## Supported Codecs +# Supported Codecs | Codec | Encode | |-------|--------| @@ -28,11 +28,11 @@ A Vulkan-based video encoding library for Rust, supporting H.264, H.265, and AV1 | H.265/HEVC | ✓ | | AV1 | ✓ | -## Requirements +# Requirements - A GPU with Vulkan video encoding support (e.g., NVIDIA RTX series, AMD RDNA2+, Intel Arc) -## Installation +# Installation Add this to your `Cargo.toml`: @@ -41,7 +41,7 @@ Add this to your `Cargo.toml`: pixelforge = "0.1" ``` -### Optional Features +## Optional Features | Feature | Description | |---------|-------------| @@ -54,9 +54,9 @@ To enable DMA-BUF support: pixelforge = { version = "0.1", features = ["dmabuf"] } ``` -## Quick Start +# Quick Start -### Query Capabilities +## Query Capabilities ```rust use pixelforge::{Codec, VideoContextBuilder}; @@ -76,7 +76,7 @@ fn main() -> Result<(), Box> { } ``` -### Encoding Video +## Encoding Video ```rust use pixelforge::{ @@ -116,7 +116,7 @@ fn main() -> Result<(), Box> { } ``` -### Color Conversion (RGB → YUV) +## Color Conversion (RGB → YUV) PixelForge includes a GPU compute shader for converting RGB input to YUV output, supporting multiple color spaces: @@ -144,7 +144,15 @@ let mut converter = ColorConverter::new(context.clone(), config)?; // converter.convert(input_image, output_buffer)?; ``` -## Examples +# Benchmarking + +Run the encode latency benchmark with: + +``` +cargo run --example encode_bench +``` + +# Examples Run the examples with: @@ -165,21 +173,21 @@ cargo run --example encode_av1 cargo run --example verify_all ``` -## Shader Development +# Shader Development The color conversion shader is precompiled to SPIR-V and embedded at build time. See [shader/README.md](shader/README.md) for details on editing and recompiling shaders. -## TODO's +# TODO's 1. [] Decoding. 1. [] B-frames support. -## Contributing +# Contributing Contributions are welcome! Please feel free to submit a Pull Request. -## Acknowledgement +# Acknowledgement This project was heavily inspired by the [vk_video_samples](https://github.com/nvpro-samples/vk_video_samples) repository by NVIDIA, which provided invaluable reference for Vulkan Video encoding. diff --git a/examples/encode_bench.rs b/examples/encode_bench.rs index c83e646..15d4e77 100644 --- a/examples/encode_bench.rs +++ b/examples/encode_bench.rs @@ -141,7 +141,7 @@ fn run_encode_test( let mut total_bytes = 0; let mut gpu_durations: Vec = Vec::new(); - let mut cpu_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 = @@ -151,7 +151,7 @@ fn run_encode_test( total_bytes += packet.data.len(); if let Some(stats) = packet.stats { gpu_durations.push(Duration::from_nanos(stats.gpu_time_ns)); - cpu_durations.push(Duration::from_nanos(stats.cpu_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>(()) @@ -185,10 +185,10 @@ fn run_encode_test( print_timing_stats("GPU", &gpu_durations); } - // Print CPU timings. - if !cpu_durations.is_empty() { - cpu_durations.sort_unstable(); - print_timing_stats("CPU", &cpu_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. @@ -207,9 +207,10 @@ fn print_timing_stats(label: &str, durations: &[Duration]) { 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: {:?}", - label, min, max, avg, p99 + "{} Encode timings: Min: {:?}, Max: {:?}, Avg: {:?}, P99: {:?}, Total: {}ms", + label, min, max, avg, p99, total ); } diff --git a/src/encoder/av1/record.rs b/src/encoder/av1/record.rs index 0e835aa..1368ea2 100644 --- a/src/encoder/av1/record.rs +++ b/src/encoder/av1/record.rs @@ -47,10 +47,6 @@ impl Av1 { unsafe { prepare_encode_command_buffer(common.device(), command_buffer, query_pool)?; } - // Reset and write start timestamp - unsafe { - reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); - } let ref_dpb_slots: Vec = self.references.iter().map(|r| r.dpb_slot).collect(); unsafe { record_dpb_barriers( @@ -334,6 +330,11 @@ impl Av1 { .consecutive_bipredictive_frame_count(0) .temporal_layer_count(1); + // Reset and write start timestamp + unsafe { + 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) diff --git a/src/encoder/h264/record.rs b/src/encoder/h264/record.rs index bd12e16..44d0f02 100644 --- a/src/encoder/h264/record.rs +++ b/src/encoder/h264/record.rs @@ -84,10 +84,6 @@ impl H264 { unsafe { prepare_encode_command_buffer(common.device(), command_buffer, query_pool)?; } - // Reset and write start timestamp - unsafe { - reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); - } let ref_dpb_slots: Vec = self.l0_references.iter().map(|r| r.dpb_slot).collect(); unsafe { record_dpb_barriers( @@ -472,6 +468,11 @@ impl H264 { .initial_virtual_buffer_size_in_ms(common.config.initial_virtual_buffer_size_ms); } + // Reset and write start timestamp + unsafe { + 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(); diff --git a/src/encoder/h265/record.rs b/src/encoder/h265/record.rs index 33cf5d2..f0e8796 100644 --- a/src/encoder/h265/record.rs +++ b/src/encoder/h265/record.rs @@ -36,10 +36,6 @@ impl H265 { unsafe { prepare_encode_command_buffer(common.device(), command_buffer, query_pool)?; } - // Reset and write start timestamp - unsafe { - reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); - } let ref_dpb_slots: Vec = self.l0_references.iter().map(|r| r.dpb_slot).collect(); unsafe { record_dpb_barriers( @@ -443,6 +439,11 @@ impl H265 { .initial_virtual_buffer_size_in_ms(common.config.initial_virtual_buffer_size_ms); } + // Reset and write start timestamp + unsafe { + 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() diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index ba80852..09beb7e 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -511,7 +511,7 @@ pub struct EncodedPacketStats { /// GPU encode time in nanoseconds pub gpu_time_ns: u64, /// CPU wall time in nanoseconds (submission + fence wait + readback) - pub cpu_time_ns: u64, + pub frame_latency_ns: u64, /// Wall latency in nanoseconds (time between submit and bitstream ready) pub wall_latency_ns: u64, } diff --git a/src/encoder/pipeline.rs b/src/encoder/pipeline.rs index 1a72f31..233506e 100644 --- a/src/encoder/pipeline.rs +++ b/src/encoder/pipeline.rs @@ -563,7 +563,7 @@ fn run_completion_thread( } { stats = Some(super::EncodedPacketStats { gpu_time_ns: gpu_encode_ns, - cpu_time_ns: work.metadata.now.elapsed().as_nanos() as u64, + 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, diff --git a/src/lib.rs b/src/lib.rs index 328598a..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: From 42755a571bf55637336a510f4b596df48c824426 Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Mon, 29 Jun 2026 18:11:14 +0300 Subject: [PATCH 11/12] fix: Update README --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9d10d9d..a2354ab 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Vulkan-based video encoding library for Rust, supporting H.264, H.265, and AV1 codecs. -# Features +## Features - **Hardware-accelerated** video encoding using Vulkan Video extensions. - **Multiple codec support**: H.264/AVC, H.265/HEVC, AV1. @@ -20,7 +20,7 @@ A Vulkan-based video encoding library for Rust, supporting H.264, H.265, and AV1 > **Note**: B-frame support is not yet implemented. Setting `b_frame_count > 0` will panic. -# Supported Codecs +## Supported Codecs | Codec | Encode | |-------|--------| @@ -28,11 +28,11 @@ A Vulkan-based video encoding library for Rust, supporting H.264, H.265, and AV1 | H.265/HEVC | ✓ | | AV1 | ✓ | -# Requirements +## Requirements - A GPU with Vulkan video encoding support (e.g., NVIDIA RTX series, AMD RDNA2+, Intel Arc) -# Installation +## Installation Add this to your `Cargo.toml`: @@ -41,7 +41,7 @@ Add this to your `Cargo.toml`: pixelforge = "0.1" ``` -## Optional Features +### Optional Features | Feature | Description | |---------|-------------| @@ -54,9 +54,9 @@ To enable DMA-BUF support: pixelforge = { version = "0.1", features = ["dmabuf"] } ``` -# Quick Start +## Quick Start -## Query Capabilities +### Query Capabilities ```rust use pixelforge::{Codec, VideoContextBuilder}; @@ -76,7 +76,7 @@ fn main() -> Result<(), Box> { } ``` -## Encoding Video +### Encoding Video ```rust use pixelforge::{ @@ -116,7 +116,7 @@ fn main() -> Result<(), Box> { } ``` -## Color Conversion (RGB → YUV) +### Color Conversion (RGB → YUV) PixelForge includes a GPU compute shader for converting RGB input to YUV output, supporting multiple color spaces: @@ -144,7 +144,7 @@ let mut converter = ColorConverter::new(context.clone(), config)?; // converter.convert(input_image, output_buffer)?; ``` -# Benchmarking +## Benchmarking Run the encode latency benchmark with: @@ -152,7 +152,7 @@ Run the encode latency benchmark with: cargo run --example encode_bench ``` -# Examples +## Examples Run the examples with: @@ -173,21 +173,21 @@ cargo run --example encode_av1 cargo run --example verify_all ``` -# Shader Development +## Shader Development The color conversion shader is precompiled to SPIR-V and embedded at build time. See [shader/README.md](shader/README.md) for details on editing and recompiling shaders. -# TODO's +## TODO's 1. [] Decoding. 1. [] B-frames support. -# Contributing +## Contributing Contributions are welcome! Please feel free to submit a Pull Request. -# Acknowledgement +## Acknowledgement This project was heavily inspired by the [vk_video_samples](https://github.com/nvpro-samples/vk_video_samples) repository by NVIDIA, which provided invaluable reference for Vulkan Video encoding. From 6e71935b023d5f7176eb0e0364ad2f997b21e9af Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Sat, 18 Jul 2026 10:40:32 +0300 Subject: [PATCH 12/12] fix: clarify comments and safety --- src/encoder/av1/record.rs | 4 +--- src/encoder/h264/record.rs | 4 +--- src/encoder/h265/record.rs | 4 +--- src/encoder/resources.rs | 13 +++++++++---- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/encoder/av1/record.rs b/src/encoder/av1/record.rs index 1368ea2..02db29d 100644 --- a/src/encoder/av1/record.rs +++ b/src/encoder/av1/record.rs @@ -331,9 +331,7 @@ impl Av1 { .temporal_layer_count(1); // Reset and write start timestamp - unsafe { - reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); - } + reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); let begin_coding_info = if is_first_frame { vk::VideoBeginCodingInfoKHR::default() diff --git a/src/encoder/h264/record.rs b/src/encoder/h264/record.rs index 44d0f02..04b00df 100644 --- a/src/encoder/h264/record.rs +++ b/src/encoder/h264/record.rs @@ -469,9 +469,7 @@ impl H264 { } // Reset and write start timestamp - unsafe { - reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); - } + 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. diff --git a/src/encoder/h265/record.rs b/src/encoder/h265/record.rs index f0e8796..70d50c7 100644 --- a/src/encoder/h265/record.rs +++ b/src/encoder/h265/record.rs @@ -440,9 +440,7 @@ impl H265 { } // Reset and write start timestamp - unsafe { - reset_start_timestamp(common.device(), command_buffer, timestamp_query_pool); - } + 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 { diff --git a/src/encoder/resources.rs b/src/encoder/resources.rs index cbe9e05..bc2fe5e 100644 --- a/src/encoder/resources.rs +++ b/src/encoder/resources.rs @@ -1596,8 +1596,8 @@ pub(crate) fn get_encoded_session_params( } } -/// Resets and writes first timestamp command -pub(crate) unsafe fn reset_start_timestamp( +/// 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, @@ -1613,7 +1613,8 @@ pub(crate) unsafe fn reset_start_timestamp( } } -pub(crate) unsafe fn end_timestamp( +/// 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, @@ -1628,7 +1629,11 @@ pub(crate) unsafe fn end_timestamp( } } -/// Queries and possibly returns the difference between reset_start_timestamp and end_timestamp calls +/// 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,