perf: eliminate GPU pipeline stalls and CPU software color conversion on Windows (v0.2.10)

This commit is contained in:
katR 2026-09-08 23:25:03 +05:30
parent 861c045a58
commit 8e8560745c
9 changed files with 227 additions and 145 deletions

2
Cargo.lock generated
View file

@ -3700,7 +3700,7 @@ dependencies = [
[[package]]
name = "scythe"
version = "0.2.9"
version = "0.2.10"
dependencies = [
"ashpd",
"cpal",

View file

@ -1,6 +1,6 @@
[package]
name = "scythe"
version = "0.2.9"
version = "0.2.10"
edition = "2024"
[[bin]]

View file

@ -307,7 +307,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(f) => f,
Err(_) => {
Frame::Raw {
data: vec![0u8; 1920 * 1080 * 4],
data: std::sync::Arc::new(vec![0u8; 1920 * 1080 * 4]),
width: 1920,
height: 1080,
stride: 1920 * 4,
@ -685,10 +685,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(Vec::new())
}
} else {
let cached = encoder.encode_cached_frame(pts);
match cached {
Ok(pkts) if !pkts.is_empty() => Ok(pkts),
_ => {
match encoder.encode_cached_frame(pts) {
Ok(pkts) => Ok(pkts),
Err(_) => {
if let Some(ref f) = latest_frame {
encoder.encode_frame(f, pts)
} else {

View file

@ -39,7 +39,7 @@ impl FrameSource for MockCapture {
width: self.width,
height: self.height,
stride: self.width * 4,
data,
data: std::sync::Arc::new(data),
})
}
}

View file

@ -12,7 +12,7 @@ pub mod x11;
#[cfg(target_os = "windows")]
pub mod windows;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum Frame {
/// Zero-copy DMA-BUF file descriptor. Used by Wayland/PipeWire on Linux.
DmaBuf {
@ -29,7 +29,7 @@ pub enum Frame {
width: u32,
height: u32,
stride: u32,
data: Vec<u8>,
data: std::sync::Arc<Vec<u8>>,
},
/// Zero-copy DirectX 11 Texture handle. Used by Windows DXGI capture.
#[cfg(target_os = "windows")]

View file

@ -91,7 +91,7 @@ impl PipeWireStream {
width: user_data.format.size().width,
height: user_data.format.size().height,
stride,
data: slice.to_vec(),
data: std::sync::Arc::new(slice.to_vec()),
}
} else {
return;

View file

@ -9,17 +9,21 @@ use windows::{
Win32::System::Com::*,
};
use crate::capture::{Frame, FrameSource};
use std::sync::Arc;
#[cfg(target_os = "windows")]
pub struct WindowsCapture {
device: ID3D11Device,
context: ID3D11DeviceContext,
duplication: IDXGIOutputDuplication,
staging_texture: ID3D11Texture2D,
staging_textures: [ID3D11Texture2D; 2],
staging_idx: usize,
has_staged_frame: bool,
pub width: u32,
pub height: u32,
pub last_stride: u32,
last_frame: Vec<u8>,
buffer_pool: Vec<Option<Arc<Vec<u8>>>>,
last_frame: Option<Arc<Vec<u8>>>,
}
#[cfg(target_os = "windows")]
@ -92,7 +96,7 @@ impl WindowsCapture {
// Initialize Desktop Duplication
let duplication = output1.DuplicateOutput(&device)?;
// Allocate a CPU-accessible staging texture for hardware frame reading
// Allocate double-buffered CPU-accessible staging textures for asynchronous zero-stall GPU readback
let staging_desc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
@ -106,21 +110,29 @@ impl WindowsCapture {
MiscFlags: 0,
};
let mut staging_texture: Option<ID3D11Texture2D> = None;
device.CreateTexture2D(&staging_desc, None, Some(&mut staging_texture))?;
let staging_texture = staging_texture.ok_or("Failed to create D3D11 staging texture")?;
let mut tex0: Option<ID3D11Texture2D> = None;
let mut tex1: Option<ID3D11Texture2D> = None;
device.CreateTexture2D(&staging_desc, None, Some(&mut tex0))?;
device.CreateTexture2D(&staging_desc, None, Some(&mut tex1))?;
let staging_textures = [
tex0.ok_or("Failed to create D3D11 staging texture 0")?,
tex1.ok_or("Failed to create D3D11 staging texture 1")?,
];
println!("Windows DXGI Hardware Desktop Duplication active: {}x{}", width, height);
println!("Windows DXGI Hardware Desktop Duplication active (asynchronous double-buffered): {}x{}", width, height);
Ok(Self {
device,
context,
duplication,
staging_texture,
staging_textures,
staging_idx: 0,
has_staged_frame: false,
width,
height,
last_stride: width * 4,
last_frame: Vec::new(),
buffer_pool: Vec::with_capacity(4),
last_frame: None,
})
}
}
@ -134,6 +146,8 @@ impl WindowsCapture {
if let Ok(output1) = output.cast::<IDXGIOutput1>() {
if let Ok(dup) = output1.DuplicateOutput(&self.device) {
self.duplication = dup;
self.has_staged_frame = false;
self.staging_idx = 0;
if let Ok(desc) = output1.GetDesc() {
let new_w = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left).unsigned_abs();
let new_h = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs();
@ -141,6 +155,8 @@ impl WindowsCapture {
self.width = new_w;
self.height = new_h;
self.last_stride = new_w * 4;
self.buffer_pool.clear();
self.last_frame = None;
let staging_desc = D3D11_TEXTURE2D_DESC {
Width: new_w,
Height: new_h,
@ -153,10 +169,14 @@ impl WindowsCapture {
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
MiscFlags: 0,
};
let mut new_staging: Option<ID3D11Texture2D> = None;
if self.device.CreateTexture2D(&staging_desc, None, Some(&mut new_staging)).is_ok()
&& let Some(st) = new_staging {
self.staging_texture = st;
let mut tex0: Option<ID3D11Texture2D> = None;
let mut tex1: Option<ID3D11Texture2D> = None;
let ok0 = self.device.CreateTexture2D(&staging_desc, None, Some(&mut tex0)).is_ok();
let ok1 = self.device.CreateTexture2D(&staging_desc, None, Some(&mut tex1)).is_ok();
if ok0 && ok1 {
if let (Some(t0), Some(t1)) = (tex0, tex1) {
self.staging_textures = [t0, t1];
}
}
}
}
@ -177,100 +197,118 @@ impl FrameSource for WindowsCapture {
let mut frame_info = DXGI_OUTDUPL_FRAME_INFO::default();
let mut desktop_resource: Option<IDXGIResource> = None;
for _ in 0..5 {
match self.duplication.AcquireNextFrame(16, &mut frame_info, &mut desktop_resource) {
for _ in 0..10 {
match self.duplication.AcquireNextFrame(25, &mut frame_info, &mut desktop_resource) {
Ok(()) => {
if let Some(resource) = desktop_resource {
let texture: ID3D11Texture2D = resource.cast()?;
// Copy GPU desktop texture to staging texture accessible by CPU
self.context.CopyResource(&self.staging_texture, &texture);
let write_idx = self.staging_idx;
let read_idx = 1 - self.staging_idx;
// Asynchronously copy GPU desktop texture into staging texture[write_idx]
self.context.CopyResource(&self.staging_textures[write_idx], &texture);
// Immediately release the desktop frame back to the DWM compositor
let _ = self.duplication.ReleaseFrame();
// Use ping-pong texture:
// On first frame, read write_idx directly.
// On subsequent frames, read read_idx (queued 1 frame ago, DMA already finished!)
let target_read = if !self.has_staged_frame {
self.has_staged_frame = true;
write_idx
} else {
read_idx
};
self.staging_idx = 1 - self.staging_idx;
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
self.context.Map(&self.staging_texture, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?;
self.context.Map(&self.staging_textures[target_read], 0, D3D11_MAP_READ, 0, Some(&mut mapped))?;
let stride = mapped.RowPitch as usize;
self.last_stride = mapped.RowPitch;
let total_bytes = stride * self.height as usize;
if self.last_frame.len() != total_bytes {
self.last_frame.resize(total_bytes, 0);
// Reusable zero-allocation buffer acquisition from pool
let mut arc_buf = None;
for slot in &mut self.buffer_pool {
if let Some(buf) = slot {
if Arc::strong_count(buf) == 1 {
arc_buf = slot.take();
break;
}
}
}
let mut arc_buf = arc_buf.unwrap_or_else(|| Arc::new(vec![0u8; total_bytes]));
{
let vec_ref = Arc::get_mut(&mut arc_buf).unwrap();
if vec_ref.len() != total_bytes {
vec_ref.resize(total_bytes, 0);
}
std::ptr::copy_nonoverlapping(
mapped.pData as *const u8,
vec_ref.as_mut_ptr(),
total_bytes,
);
}
self.context.Unmap(&self.staging_textures[target_read], 0);
let to_send = Arc::clone(&arc_buf);
self.last_frame = Some(Arc::clone(&arc_buf));
// Return to buffer pool
if self.buffer_pool.len() < 4 {
self.buffer_pool.push(Some(arc_buf));
}
std::ptr::copy_nonoverlapping(
mapped.pData as *const u8,
self.last_frame.as_mut_ptr(),
total_bytes,
);
self.context.Unmap(&self.staging_texture, 0);
return Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: stride as u32,
data: self.last_frame.clone(),
data: to_send,
});
}
let _ = self.duplication.ReleaseFrame();
}
Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => {
if !self.last_frame.is_empty() {
return Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.last_stride,
data: self.last_frame.clone(),
});
}
std::thread::sleep(std::time::Duration::from_millis(8));
// Desktop static / no new frame presented. Sleep briefly and wait.
// Do NOT allocate duplicate frames!
std::thread::sleep(std::time::Duration::from_millis(3));
continue;
}
Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST
|| e.code() == DXGI_ERROR_ACCESS_DENIED
|| e.code() == DXGI_ERROR_INVALID_CALL => {
let _ = self.duplication.ReleaseFrame();
let _ = self.reinit_duplication();
if !self.last_frame.is_empty() {
return Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.last_stride,
data: self.last_frame.clone(),
});
}
std::thread::sleep(std::time::Duration::from_millis(30));
std::thread::sleep(std::time::Duration::from_millis(20));
continue;
}
Err(e) => {
let _ = self.duplication.ReleaseFrame();
let _ = self.reinit_duplication();
if !self.last_frame.is_empty() {
return Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.last_stride,
data: self.last_frame.clone(),
});
}
std::thread::sleep(std::time::Duration::from_millis(16));
return Err(Box::new(e));
}
}
}
if !self.last_frame.is_empty() {
// If desktop was completely idle across multiple iterations,
// return zero-copy reference to last frame so stream stays active without heap thrashing
if let Some(ref last) = self.last_frame {
Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.last_stride,
data: self.last_frame.clone(),
data: Arc::clone(last),
})
} else {
Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.last_stride,
data: vec![0u8; (self.last_stride * self.height) as usize],
data: Arc::new(vec![0u8; (self.last_stride * self.height) as usize]),
})
}
}

View file

@ -103,7 +103,7 @@ impl FrameSource for X11Capture {
width: self.width as u32,
height: self.height as u32,
stride,
data,
data: std::sync::Arc::new(data),
});
}
@ -125,7 +125,7 @@ impl FrameSource for X11Capture {
width: self.width as u32,
height: self.height as u32,
stride,
data,
data: std::sync::Arc::new(data),
})
}
}

View file

@ -135,7 +135,7 @@ impl VaapiEncoder {
width as i32,
height as i32,
AVPixelFormat::AV_PIX_FMT_NV12,
2,
1,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
@ -384,6 +384,7 @@ pub struct WindowsHwEncoder {
sws_ctx: *mut SwsContext,
sw_frame: *mut AVFrame,
pub has_frame: bool,
native_bgr: bool,
}
#[cfg(target_os = "windows")]
@ -422,6 +423,7 @@ impl WindowsHwEncoder {
let mut opened_codec_ctx: *mut AVCodecContext = ptr::null_mut();
let mut selected_desc = String::new();
let mut is_native_bgr = false;
for (name, desc) in candidates {
let c = avcodec_find_encoder_by_name(name.as_ptr());
@ -429,60 +431,77 @@ impl WindowsHwEncoder {
continue;
}
let ctx = avcodec_alloc_context3(c);
if ctx.is_null() {
continue;
}
(*ctx).width = width as i32;
(*ctx).height = height as i32;
(*ctx).time_base = AVRational { num: 1, den: fps };
(*ctx).framerate = AVRational { num: fps, den: 1 };
(*ctx).gop_size = fps;
(*ctx).max_b_frames = 0;
let rate = (bitrate_kbps as i64) * 1000;
(*ctx).bit_rate = rate;
(*ctx).rc_max_rate = rate * 3 / 2;
(*ctx).rc_buffer_size = (rate / 2) as i32;
(*ctx).qmin = 16;
(*ctx).qmax = 28;
let codec_id = (*c).id;
(*ctx).profile = if codec_id == AVCodecID::AV_CODEC_ID_HEVC {
FF_PROFILE_HEVC_MAIN
} else if codec_id == AVCodecID::AV_CODEC_ID_AV1 {
FF_PROFILE_UNKNOWN
} else {
FF_PROFILE_H264_HIGH
};
(*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int;
if desc.contains("NVENC") {
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"p1".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"ull".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"forced-idr".as_ptr(), c"1".as_ptr(), 0);
} else if desc.contains("AMF") {
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*ctx).priv_data, c"usage".as_ptr(), c"ultralowlatency".as_ptr(), 0);
// Prioritize native BGR0 for hardware encoders (NVENC, AMF) to eliminate CPU color conversion
let formats_to_try: &[AVPixelFormat] = if desc.contains("NVENC") || desc.contains("AMF") {
&[AVPixelFormat::AV_PIX_FMT_BGR0, AVPixelFormat::AV_PIX_FMT_NV12]
} else if desc.contains("QuickSync") {
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"veryfast".as_ptr(), 0);
&[AVPixelFormat::AV_PIX_FMT_NV12]
} else {
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_YUV420P;
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"ultrafast".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"zerolatency".as_ptr(), 0);
&[AVPixelFormat::AV_PIX_FMT_YUV420P]
};
for &pix_fmt in formats_to_try {
let ctx = avcodec_alloc_context3(c);
if ctx.is_null() {
continue;
}
(*ctx).width = width as i32;
(*ctx).height = height as i32;
(*ctx).time_base = AVRational { num: 1, den: fps };
(*ctx).framerate = AVRational { num: fps, den: 1 };
(*ctx).gop_size = fps;
(*ctx).max_b_frames = 0;
let rate = (bitrate_kbps as i64) * 1000;
(*ctx).bit_rate = rate;
(*ctx).rc_max_rate = rate * 3 / 2;
(*ctx).rc_buffer_size = (rate / 2) as i32;
(*ctx).qmin = 16;
(*ctx).qmax = 28;
let codec_id = (*c).id;
(*ctx).profile = if codec_id == AVCodecID::AV_CODEC_ID_HEVC {
FF_PROFILE_HEVC_MAIN
} else if codec_id == AVCodecID::AV_CODEC_ID_AV1 {
FF_PROFILE_UNKNOWN
} else {
FF_PROFILE_H264_HIGH
};
(*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int;
(*ctx).pix_fmt = pix_fmt;
if desc.contains("NVENC") {
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"p1".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"ull".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"forced-idr".as_ptr(), c"1".as_ptr(), 0);
if pix_fmt == AVPixelFormat::AV_PIX_FMT_BGR0 {
let _ = av_opt_set((*ctx).priv_data, c"rgb_mode".as_ptr(), c"yuv420".as_ptr(), 0);
}
} else if desc.contains("AMF") {
let _ = av_opt_set((*ctx).priv_data, c"usage".as_ptr(), c"ultralowlatency".as_ptr(), 0);
} else if desc.contains("QuickSync") {
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"veryfast".as_ptr(), 0);
} else {
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"ultrafast".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"zerolatency".as_ptr(), 0);
}
let ret = avcodec_open2(ctx, c, ptr::null_mut());
if ret >= 0 {
let native = pix_fmt == AVPixelFormat::AV_PIX_FMT_BGR0;
println!("Successfully initialized Windows video encoder: {} (native hardware BGR: {})", desc, native);
opened_codec_ctx = ctx;
selected_desc = desc.to_string();
is_native_bgr = native;
break;
} else {
let mut tmp = ctx;
avcodec_free_context(&mut tmp);
}
}
let ret = avcodec_open2(ctx, c, ptr::null_mut());
if ret >= 0 {
println!("Successfully initialized Windows video encoder: {}", desc);
opened_codec_ctx = ctx;
selected_desc = desc.to_string();
if !opened_codec_ctx.is_null() {
break;
} else {
let mut tmp = ctx;
avcodec_free_context(&mut tmp);
}
}
@ -505,18 +524,23 @@ impl WindowsHwEncoder {
return Err("Failed to allocate frame buffer for encoder".into());
}
let sws_ctx = sws_getContext(
width as i32,
height as i32,
AVPixelFormat::AV_PIX_FMT_BGRA,
width as i32,
height as i32,
(*codec_ctx).pix_fmt,
2,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
);
// Only initialize sws_ctx if color conversion is required (e.g. QSV or CPU fallback)
let sws_ctx = if !is_native_bgr {
sws_getContext(
width as i32,
height as i32,
AVPixelFormat::AV_PIX_FMT_BGRA,
width as i32,
height as i32,
(*codec_ctx).pix_fmt,
1, // SWS_FAST_BILINEAR for SIMD acceleration
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
)
} else {
ptr::null_mut()
};
Ok(Self {
codec_ctx,
@ -524,6 +548,7 @@ impl WindowsHwEncoder {
sws_ctx,
sw_frame,
has_frame: false,
native_bgr: is_native_bgr,
})
}
}
@ -537,22 +562,43 @@ impl WindowsHwEncoder {
unsafe {
match frame {
Frame::Raw { width: _, height, stride, data } => {
let src_data = [data.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_linesize = [*stride as i32, 0, 0, 0];
if av_frame_make_writable(self.sw_frame) < 0 {
let _ = av_frame_get_buffer(self.sw_frame, 32);
}
sws_scale(
self.sws_ctx,
src_data.as_ptr(),
src_linesize.as_ptr(),
0,
*height as i32,
(*self.sw_frame).data.as_mut_ptr(),
(*self.sw_frame).linesize.as_mut_ptr(),
);
if self.native_bgr {
// Direct hardware encoding: zero CPU color conversion
let dst_data = (*self.sw_frame).data[0];
let dst_linesize = (*self.sw_frame).linesize[0] as usize;
let src_stride = *stride as usize;
let row_bytes = ((*self.sw_frame).width as usize * 4).min(src_stride).min(dst_linesize);
if dst_linesize == src_stride && row_bytes == src_stride {
std::ptr::copy_nonoverlapping(
data.as_ptr(),
dst_data,
row_bytes * *height as usize,
);
} else {
for y in 0..(*height as usize) {
let src_row = data.as_ptr().add(y * src_stride);
let dst_row = dst_data.add(y * dst_linesize);
std::ptr::copy_nonoverlapping(src_row, dst_row, row_bytes);
}
}
} else if !self.sws_ctx.is_null() {
let src_data = [data.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_linesize = [*stride as i32, 0, 0, 0];
sws_scale(
self.sws_ctx,
src_data.as_ptr(),
src_linesize.as_ptr(),
0,
*height as i32,
(*self.sw_frame).data.as_mut_ptr(),
(*self.sw_frame).linesize.as_mut_ptr(),
);
}
(*self.sw_frame).pts = pts;
@ -569,7 +615,6 @@ impl WindowsHwEncoder {
}
#[cfg(target_os = "windows")]
Frame::D3D11Texture { handle: _, .. } => {
// Zero-copy D3D11 frame submission
(*self.sw_frame).pts = pts;
if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 {
@ -850,7 +895,7 @@ mod tests {
width: 1280,
height: 720,
stride: 1280 * 4,
data: vec![0u8; 1280 * 720 * 4],
data: std::sync::Arc::new(vec![0u8; 1280 * 720 * 4]),
};
let pkts = enc.encode_frame(&blank_frame, 0).expect("Encode frame");
println!("Encoded {} packets from first frame", pkts.len());