perf(encode/nvenc-linux): LN2 v1 — stream-ordered submit via NvEncSetIOCudaStreams

Bind the session's IO streams to the encode thread's high-priority copy
stream: in sync-retrieve depth-1 use the per-frame input copy and cursor
blend now enqueue with NO cuStreamSynchronize and encode_picture orders
after them on the stream. Same stream both directions, so the encode's
completion is inserted into the stream and later work (the next frame's
copy into a reused ring slot) waits for it.

Soundness gate: the fast path engages only when pending is empty (true
depth-1 usage) — every prior encode was drained by a blocking poll, and
the caller holds the frame payload across the matching poll (contract now
documented on Encoder::submit; both host loops already comply). Pipelined
callers and PUNKTFUNK_NVENC_ASYNC mode keep the blocking copies.

True zero-copy input registration (registering the worker-owned IPC
buffer directly) stays the LN2 v2 follow-up — it needs a contiguous
worker-pool NV12 layout and a registration<->IPC-mapping lifetime tie.

PUNKTFUNK_NVENC_STREAM_ORDERED=0 restores the old blocking behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:29:38 +02:00
parent 59b6d3796b
commit cd36c46388
4 changed files with 199 additions and 39 deletions
+5
View File
@@ -219,6 +219,11 @@ pub struct EncoderCaps {
/// A hardware encoder. One per session; runs on the encode thread. /// A hardware encoder. One per session; runs on the encode thread.
pub trait Encoder: Send { pub trait Encoder: Send {
/// Submit one captured frame for encoding. Lifetime contract: the caller must keep `frame`
/// (and its GPU payload) alive until this frame's AU has been returned by
/// [`poll`](Self::poll) — a stream-ordered backend (Linux direct-NVENC's IO-stream binding)
/// may still be reading the payload asynchronously after `submit` returns. Both host encode
/// loops already hold the frame across their poll drain; new callers must do the same.
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>; fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the /// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The /// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
+3 -3
View File
@@ -826,7 +826,7 @@ impl NvencEncoder {
(*f).linesize[i] as usize, (*f).linesize[i] as usize,
) )
}); });
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts) pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
} else if self.want_444 { } else if self.want_444 {
ffi::av_frame_free(&mut f); ffi::av_frame_free(&mut f);
bail!( bail!(
@@ -839,11 +839,11 @@ impl NvencEncoder {
let y_pitch = (*f).linesize[0] as usize; let y_pitch = (*f).linesize[0] as usize;
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr; let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
let uv_pitch = (*f).linesize[1] as usize; let uv_pitch = (*f).linesize[1] as usize;
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch) pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
} else { } else {
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr; let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
let dst_pitch = (*f).linesize[0] as usize; let dst_pitch = (*f).linesize[0] as usize;
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch) pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
}; };
if let Err(e) = copy_res { if let Err(e) = copy_res {
ffi::av_frame_free(&mut f); ffi::av_frame_free(&mut f);
+117 -15
View File
@@ -16,9 +16,14 @@
//! ([`zerocopy::cuda::InputSurface`]): each captured `FramePayload::Cuda` `DeviceBuffer` is //! ([`zerocopy::cuda::InputSurface`]): each captured `FramePayload::Cuda` `DeviceBuffer` is
//! device→device copied into the current ring slot (via the existing `copy_*_to_device` //! device→device copied into the current ring slot (via the existing `copy_*_to_device`
//! helpers) before `encode_picture`. This mirrors the libav path's recycled-hwframe-pool copy //! helpers) before `encode_picture`. This mirrors the libav path's recycled-hwframe-pool copy
//! (NVENC rejects a null-`buf[0]` frame and its CUDADEVICEPTR registration cache is bounded + //! (NVENC rejects a null-`buf[0]` frame; the captured buffer is worker-owned CUDA-IPC memory
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is //! recycled on drop, so registering it directly needs a contiguous worker-pool layout + a
//! zero regression versus today; true zero-copy input registration is a follow-up. //! registration↔IPC-mapping lifetime tie — the true zero-copy follow-up, plan §7 LN2 v2).
//! **Stream-ordered submit** (default, `PUNKTFUNK_NVENC_STREAM_ORDERED=0` reverts): the
//! session's IO streams are bound to the encode thread's copy stream
//! (`NvEncSetIOCudaStreams`), so in sync-retrieve depth-1 use the copy + cursor blend enqueue
//! with NO per-frame `cuStreamSynchronize` and the encode orders after them on the stream —
//! the submit path's CPU stalls are gone even though the copy itself remains.
//! //!
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows //! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows
//! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode* //! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode*
@@ -120,6 +125,14 @@ struct EncodeApi {
encode_picture: encode_picture:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS, unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS, invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
/// `NvEncSetIOCudaStreams` — binds the session's input/output ordering to a CUDA stream so the
/// input copy + cursor blend can enqueue without a CPU sync (stream-ordered submit). The two
/// `NV_ENC_CUSTREAM_PTR` args are pointers TO `CUstream` values.
set_io_cuda_streams: unsafe extern "C" fn(
*mut c_void,
nv::NV_ENC_CUSTREAM_PTR,
nv::NV_ENC_CUSTREAM_PTR,
) -> nv::NVENCSTATUS,
} }
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so, /// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
@@ -212,6 +225,7 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?, unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?, encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?, invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
set_io_cuda_streams: list.nvEncSetIOCudaStreams.ok_or(MISSING)?,
}; };
std::mem::forget(lib); // keep the .so mapped for the fn pointers' lifetime (process) std::mem::forget(lib); // keep the .so mapped for the fn pointers' lifetime (process)
Ok(api) Ok(api)
@@ -245,6 +259,18 @@ fn async_inflight_cap() -> usize {
.clamp(2, POOL - 1) .clamp(2, POOL - 1)
} }
/// Stream-ordered submit (`PUNKTFUNK_NVENC_STREAM_ORDERED`, default ON; `0` = the pre-existing
/// blocking copies). With the session's IO streams bound to the encode thread's copy stream
/// (`NvEncSetIOCudaStreams`), the input copy + cursor blend enqueue with NO CPU sync and
/// `encode_picture` orders after them on the stream — deleting the 13 per-frame
/// `cuStreamSynchronize` stalls from the submit path (latency plan §7 LN2). Sync-retrieve mode
/// only, and only while nothing is in flight (see the gate in [`Encoder::submit`]).
fn stream_ordered_requested() -> bool {
std::env::var("PUNKTFUNK_NVENC_STREAM_ORDERED")
.map(|v| v.trim() != "0")
.unwrap_or(true)
}
/// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock. /// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock.
/// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before /// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before
/// the session it belongs to is destroyed). /// the session it belongs to is destroyed).
@@ -460,6 +486,14 @@ pub struct NvencCudaEncoder {
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default /// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`. /// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
async_rt: Option<AsyncRetrieve>, async_rt: Option<AsyncRetrieve>,
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
/// session is destroyed.
io_stream: *mut *mut c_void,
/// Stream-ordered submit armed for the live session (sync-retrieve mode only; see
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
stream_ordered: bool,
} }
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext` // SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
@@ -539,6 +573,8 @@ impl NvencCudaEncoder {
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32, split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
last_rfi_range: None, last_rfi_range: None,
async_rt: None, async_rt: None,
io_stream: ptr::null_mut(),
stream_ordered: false,
}) })
} }
@@ -578,6 +614,14 @@ impl NvencCudaEncoder {
session's slot toward the concurrent-session cap" session's slot toward the concurrent-session cap"
); );
} }
// The boxed CUstream the IO-stream binding pointed at — freed only now, AFTER the session
// that referenced it is destroyed (created by `Box::into_raw` in `init_session`, freed
// exactly once here; `io_stream` is nulled so a re-init can't double-free).
if !self.io_stream.is_null() {
drop(Box::from_raw(self.io_stream));
self.io_stream = ptr::null_mut();
}
self.stream_ordered = false;
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
self.bitstreams.clear(); self.bitstreams.clear();
self.pending.clear(); self.pending.clear();
@@ -834,7 +878,11 @@ impl NvencCudaEncoder {
// `try_open_session` just returned (and `best` only when non-null). `create_bitstream_buffer` // `try_open_session` just returned (and `best` only when non-null). `create_bitstream_buffer`
// and `register_resource` take `enc`, the chosen live session, and `&mut` locals whose // and `register_resource` take `enc`, the chosen live session, and `&mut` locals whose
// `version` is set and which outlive the synchronous call. `InputSurface::alloc_*` returns a // `version` is set and which outlive the synchronous call. `InputSurface::alloc_*` returns a
// live pitched CUDA allocation on the shared context. No handle escapes the encode thread. // live pitched CUDA allocation on the shared context. `set_io_cuda_streams` takes `enc` plus
// two pointers to the boxed live `CUstream` (`Box::into_raw`), which outlives the session —
// freed exactly once: in `teardown` after `destroy_encoder` when armed, or via
// `Box::from_raw` right here on the rejection path (where `io_stream` is never set). No
// handle escapes the encode thread.
unsafe { unsafe {
// Bind to the shared CUDA context; make it current on this (encode) thread for both the // Bind to the shared CUDA context; make it current on this (encode) thread for both the
// session open and every subsequent device→device input copy. // session open and every subsequent device→device input copy.
@@ -994,6 +1042,45 @@ impl NvencCudaEncoder {
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)" "NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
); );
} }
// Stream-ordered submit (latency plan §7 LN2): bind the session's IO streams to this
// thread's copy stream so the input copy + cursor blend enqueue with no CPU sync and
// `encode_picture` orders after them. Same stream both ways: input-stream semantics
// start the encode only after our enqueued copies, output-stream semantics insert the
// encode's completion INTO the stream — so later stream work (the next frame's copy
// into a reused ring slot) also waits for it. Sync-retrieve mode only: in two-thread
// mode the captured buffer may be recycled after `submit` returns while the stream
// still holds its copy (the blocking copies are the lifetime guarantee there).
if self.async_rt.is_none() && stream_ordered_requested() {
let stream = cuda::copy_stream_handle();
if !stream.is_null() {
// The pointee must outlive the session (the driver takes CUstream POINTERS) —
// box it; `teardown` frees it after `destroy_encoder`.
let holder = Box::into_raw(Box::new(stream));
match (api().set_io_cuda_streams)(
enc,
holder as nv::NV_ENC_CUSTREAM_PTR,
holder as nv::NV_ENC_CUSTREAM_PTR,
)
.nv_ok()
{
Ok(()) => {
self.io_stream = holder;
self.stream_ordered = true;
tracing::info!(
"NVENC stream-ordered submit armed (IO streams bound — no CPU \
sync in the submit path)"
);
}
Err(e) => {
drop(Box::from_raw(holder));
tracing::debug!(
status = ?e,
"NvEncSetIOCudaStreams rejected — keeping blocking copies"
);
}
}
}
}
tracing::info!( tracing::info!(
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps), mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
bit_depth = self.bit_depth, bit_depth = self.bit_depth,
@@ -1007,8 +1094,10 @@ impl NvencCudaEncoder {
} }
/// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device /// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device
/// on the shared context, synchronized by the copy helpers). /// on the shared context). `sync` blocks until the copy completes (the pre-existing behavior);
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize) -> Result<()> { /// `!sync` enqueues on the encode thread's copy stream and leaves ordering to the session's
/// IO-stream binding (stream-ordered submit — see the gate in [`Encoder::submit`]).
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize, sync: bool) -> Result<()> {
let s = &self.ring[slot].surface; let s = &self.ring[slot].surface;
let base = s.ptr; let base = s.ptr;
let pitch = s.pitch; let pitch = s.pitch;
@@ -1023,16 +1112,16 @@ impl NvencCudaEncoder {
(base + pitch as u64 * hh, pitch), (base + pitch as u64 * hh, pitch),
(base + 2 * pitch as u64 * hh, pitch), (base + 2 * pitch as u64 * hh, pitch),
]; ];
cuda::copy_yuv444_to_device(buf, planes) cuda::copy_yuv444_to_device(buf, planes, sync)
} }
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => { nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
if !buf.is_nv12() { if !buf.is_nv12() {
bail!("NV12 session but the captured buffer has no chroma plane"); bail!("NV12 session but the captured buffer has no chroma plane");
} }
// Contiguous NV12: UV follows Y at base + pitch*height, same pitch. // Contiguous NV12: UV follows Y at base + pitch*height, same pitch.
cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch) cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch, sync)
} }
_ => cuda::copy_device_to_device(buf, base, pitch), _ => cuda::copy_device_to_device(buf, base, pitch, sync),
} }
} }
@@ -1149,10 +1238,20 @@ impl Encoder for NvencCudaEncoder {
// loop's `submit_us` folds all four together; this is what splits them apart. // loop's `submit_us` folds all four together; this is what splits them apart.
let sample = pf_host_config::config().perf && self.frames % 120 == 0; let sample = pf_host_config::config().perf && self.frames % 120 == 0;
self.frames += 1; self.frames += 1;
// Stream-ordered fast path (§7 LN2): enqueue the copy + blend with no CPU sync and let the
// IO-stream binding order `encode_picture` after them — but ONLY while nothing is in
// flight (true depth-1 usage). The gate is what makes this sound: with `pending` empty,
// every prior encode was drained by a blocking `poll`, so (a) the ring slot being reused
// was fully read, and (b) the caller still holds this frame's payload across the matching
// `poll` (both host loops do — see `Encoder::submit`'s doc), which blocks until the encode
// (and therefore the enqueued copy) completed. A pipelined caller (pending non-empty)
// falls back to the blocking copy so an early-recycled source can never be read late.
let ordered = self.stream_ordered && self.pending.is_empty();
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
// Copy the captured buffer into this slot's input surface before encoding it. // Copy the captured buffer into this slot's input surface before encoding it.
self.copy_into_slot(buf, slot)?; self.copy_into_slot(buf, slot, !ordered)?;
let t_copy = t0.elapsed(); let t_copy = t0.elapsed();
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel // Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel
@@ -1196,12 +1295,12 @@ impl Encoder for NvencCudaEncoder {
let (w, h) = (self.width, s.height); let (w, h) = (self.width, s.height);
let r = match self.buffer_fmt { let r = match self.buffer_fmt {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => { nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y) cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered)
} }
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => { nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y) cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered)
} }
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y), _ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y, !ordered),
}; };
if let Err(e) = r { if let Err(e) = r {
if !self.cursor_blend_warned { if !self.cursor_blend_warned {
@@ -1227,8 +1326,11 @@ impl Encoder for NvencCudaEncoder {
// `pic` (`NV_ENC_PIC_PARAMS`, version set) points `inputBuffer` at `mp.mappedResource` and // `pic` (`NV_ENC_PIC_PARAMS`, version set) points `inputBuffer` at `mp.mappedResource` and
// `outputBitstream` at the live pool bitstream `bitstreams[slot]`; the optional SEI scratch is // `outputBitstream` at the live pool bitstream `bitstreams[slot]`; the optional SEI scratch is
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was // stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
// just filled by the (synchronized) device→device copy and is not overwritten until this slot // just filled by the device→device copy — either synchronized (blocking mode) or ordered
// is reused POOL submits later, by which time this encode was polled (POOL ≥ in-flight depth). // before this encode by the session's IO-stream binding (`ordered` — same stream, see the
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
// blocking lock additionally proves the enqueued copy completed).
unsafe { unsafe {
let reg = self.ring[slot].reg; let reg = self.ring[slot].reg;
let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE { let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE {
+74 -21
View File
@@ -251,6 +251,32 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
ck(cuStreamSynchronize(stream), "cuStreamSynchronize") ck(cuStreamSynchronize(stream), "cuStreamSynchronize")
} }
/// Issue `copy` on this thread's priority stream WITHOUT waiting — for stream-ordered consumers
/// only (the direct-NVENC submit path with `NvEncSetIOCudaStreams` bound to this stream): the
/// stream, not the CPU, orders completion, so the SOURCE must stay valid until the downstream
/// stream work (the encode) has finished.
unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
}
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
if sync {
copy_blocking(copy, what)
} else {
copy_async(copy, what)
}
}
/// The calling thread's copy/launch stream as a raw handle, for binding external stream-ordering
/// (the direct-NVENC `NvEncSetIOCudaStreams` hookup). Null = the NULL stream (priority-stream
/// creation failed) — callers should treat null as "stream-ordering unavailable" and keep their
/// blocking copies. The shared context must be current on this thread.
pub fn copy_stream_handle() -> *mut c_void {
copy_stream() // CUstream IS *mut c_void (opaque CUstream_st*)
}
/// Max cursor-overlay bitmap edge (px) uploaded to the device blend buffer — matches the Vulkan path. /// Max cursor-overlay bitmap edge (px) uploaded to the device blend buffer — matches the Vulkan path.
pub const CURSOR_MAX: u32 = 256; pub const CURSOR_MAX: u32 = 256;
@@ -354,6 +380,7 @@ impl CursorBlend {
ch: u32, ch: u32,
ox: i32, ox: i32,
oy: i32, oy: i32,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let (mut a_surf, mut a_cur) = (surf, self.cur_buf); let (mut a_surf, mut a_cur) = (surf, self.cur_buf);
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32); let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
@@ -370,7 +397,7 @@ impl CursorBlend {
&mut a_ox as *mut _ as *mut c_void, &mut a_ox as *mut _ as *mut c_void,
&mut a_oy as *mut _ as *mut c_void, &mut a_oy as *mut _ as *mut c_void,
]; ];
self.launch(self.f_argb, a_cw as u32, a_ch as u32, &mut args) self.launch(self.f_argb, a_cw as u32, a_ch as u32, &mut args, sync)
} }
/// Blend into an owned planar YUV444 surface (3 stacked full-res planes) at `(ox,oy)`. /// Blend into an owned planar YUV444 surface (3 stacked full-res planes) at `(ox,oy)`.
@@ -385,6 +412,7 @@ impl CursorBlend {
ch: u32, ch: u32,
ox: i32, ox: i32,
oy: i32, oy: i32,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let (mut a_base, mut a_cur) = (base, self.cur_buf); let (mut a_base, mut a_cur) = (base, self.cur_buf);
let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32); let (mut a_pitch, mut a_w, mut a_h) = (pitch as i32, w as i32, h as i32);
@@ -401,7 +429,7 @@ impl CursorBlend {
&mut a_ox as *mut _ as *mut c_void, &mut a_ox as *mut _ as *mut c_void,
&mut a_oy as *mut _ as *mut c_void, &mut a_oy as *mut _ as *mut c_void,
]; ];
self.launch(self.f_yuv444, a_cw as u32, a_ch as u32, &mut args) self.launch(self.f_yuv444, a_cw as u32, a_ch as u32, &mut args, sync)
} }
/// Blend into an owned NV12 surface (Y plane at `base`, interleaved UV at `base + pitch*h`). /// Blend into an owned NV12 surface (Y plane at `base`, interleaved UV at `base + pitch*h`).
@@ -416,6 +444,7 @@ impl CursorBlend {
ch: u32, ch: u32,
ox: i32, ox: i32,
oy: i32, oy: i32,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let (mut a_yb, mut a_uvb, mut a_cur) = (base, base + pitch as u64 * h as u64, self.cur_buf); let (mut a_yb, mut a_uvb, mut a_cur) = (base, base + pitch as u64 * h as u64, self.cur_buf);
let (mut a_yp, mut a_uvp) = (pitch as i32, pitch as i32); let (mut a_yp, mut a_uvp) = (pitch as i32, pitch as i32);
@@ -441,16 +470,20 @@ impl CursorBlend {
(a_cw as u32).div_ceil(2), (a_cw as u32).div_ceil(2),
(a_ch as u32).div_ceil(2), (a_ch as u32).div_ceil(2),
&mut args, &mut args,
sync,
) )
} }
/// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream, then synchronize. /// Launch `f` over a `work_w × work_h` grid (16×16 blocks) on the copy stream; `sync` waits
/// for it, `!sync` leaves completion to the stream (stream-ordered consumers only — the
/// kernel PARAMETERS are copied at launch time, so the arg locals need not outlive the call).
fn launch( fn launch(
&self, &self,
f: CUfunction, f: CUfunction,
work_w: u32, work_w: u32,
work_h: u32, work_h: u32,
args: &mut [*mut c_void], args: &mut [*mut c_void],
sync: bool,
) -> Result<()> { ) -> Result<()> {
if work_w == 0 || work_h == 0 { if work_w == 0 || work_h == 0 {
return Ok(()); return Ok(());
@@ -458,9 +491,11 @@ impl CursorBlend {
const B: u32 = 16; const B: u32 = 16;
let stream = copy_stream(); let stream = copy_stream();
// SAFETY: `f` is a resolved kernel from our loaded module; `args` holds pointers to live // SAFETY: `f` is a resolved kernel from our loaded module; `args` holds pointers to live
// locals whose types match the kernel's C parameters (per the call site above); grid/block // locals whose types match the kernel's C parameters (per the call site above) — CUDA
// dims are non-zero. Launched on the copy stream (ordered after the input-surface copy that // copies the parameter values during `cuLaunchKernel` itself, so they need not outlive
// `copy_into_slot` already synchronized) then synchronized. Requires the context current. // the call. Grid/block dims are non-zero. Launched on the copy stream (ordered after the
// input-surface copy issued on the same stream); `sync` waits, `!sync` leaves ordering to
// the stream (the NVENC IO-stream binding). Requires the context current.
unsafe { unsafe {
ck( ck(
cuLaunchKernel( cuLaunchKernel(
@@ -478,7 +513,10 @@ impl CursorBlend {
), ),
"cuLaunchKernel(cursor)", "cuLaunchKernel(cursor)",
)?; )?;
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)") if sync {
ck(cuStreamSynchronize(stream), "cuStreamSynchronize(cursor)")?;
}
Ok(())
} }
} }
} }
@@ -1128,10 +1166,13 @@ pub fn copy_mapped_yuv444(
/// Copy a pitched device buffer into another device region (device→device), e.g. our imported /// Copy a pitched device buffer into another device region (device→device), e.g. our imported
/// [`DeviceBuffer`] into a pooled CUDA surface NVENC owns. Both are 4-byte (BGRx) pixels. /// [`DeviceBuffer`] into a pooled CUDA surface NVENC owns. Both are 4-byte (BGRx) pixels.
/// The caller must have the shared context current on this thread (see [`make_current`]). /// The caller must have the shared context current on this thread (see [`make_current`]).
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
pub fn copy_device_to_device( pub fn copy_device_to_device(
src: &DeviceBuffer, src: &DeviceBuffer,
dst_ptr: CUdeviceptr, dst_ptr: CUdeviceptr,
dst_pitch: usize, dst_pitch: usize,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let copy = CUDA_MEMCPY2D { let copy = CUDA_MEMCPY2D {
srcMemoryType: CU_MEMORYTYPE_DEVICE, srcMemoryType: CU_MEMORYTYPE_DEVICE,
@@ -1144,23 +1185,27 @@ pub fn copy_device_to_device(
Height: src.height as usize, Height: src.height as usize,
..Default::default() ..Default::default()
}; };
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared // SAFETY: `copy_issue` is unsafe (issues a CUDA copy); the caller must have the shared
// context current (documented). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving // context current (documented). `&copy` is a live local device→device `CUDA_MEMCPY2D` outliving
// the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/ // the enqueue: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/`dstPitch` the
// `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table. // caller's live region, `width*4`×`height` within both; `sync: false` shifts the source-
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(dev->dev)") } // lifetime obligation to the caller (documented above). Wrapper → live table.
unsafe { copy_issue(&copy, "cuMemcpy2DAsync_v2(dev->dev)", sync) }
} }
/// Copy our imported NV12 [`DeviceBuffer`] (Y + UV planes) into NVENC's two-plane CUDA surface /// Copy our imported NV12 [`DeviceBuffer`] (Y + UV planes) into NVENC's two-plane CUDA surface
/// `(y_dst, y_pitch)` / `(uv_dst, uv_pitch)` (`av_hwframe_get_buffer`'s `data[0]`/`data[1]` + /// `(y_dst, y_pitch)` / `(uv_dst, uv_pitch)` (`av_hwframe_get_buffer`'s `data[0]`/`data[1]` +
/// `linesize[0]`/`linesize[1]`). The Y plane is `width`×`height` bytes; the chroma plane is /// `linesize[0]`/`linesize[1]`). The Y plane is `width`×`height` bytes; the chroma plane is
/// `(width/2)·2` bytes × `height/2` rows. The caller must have the shared context current. /// `(width/2)·2` bytes × `height/2` rows. The caller must have the shared context current.
/// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
pub fn copy_nv12_to_device( pub fn copy_nv12_to_device(
src: &DeviceBuffer, src: &DeviceBuffer,
y_dst: CUdeviceptr, y_dst: CUdeviceptr,
y_pitch: usize, y_pitch: usize,
uv_dst: CUdeviceptr, uv_dst: CUdeviceptr,
uv_pitch: usize, uv_pitch: usize,
sync: bool,
) -> Result<()> { ) -> Result<()> {
let (src_uv_ptr, src_uv_pitch) = src let (src_uv_ptr, src_uv_pitch) = src
.uv .uv
@@ -1189,15 +1234,16 @@ pub fn copy_nv12_to_device(
Height: h / 2, Height: h / 2,
..Default::default() ..Default::default()
}; };
// SAFETY: two unsafe `copy_blocking` device→device copies; the caller must have the shared // SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each // context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
// synchronous call. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live // enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are // NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy // the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
// `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table. // `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation
// to the caller (documented above). Wrappers → live table.
unsafe { unsafe {
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?; copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?;
copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)") copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync)
} }
} }
@@ -1205,7 +1251,13 @@ pub fn copy_nv12_to_device(
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context). /// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
/// Each plane is `width`×`height` bytes; the source planes sit at row offsets `0/H/2H` of the /// Each plane is `width`×`height` bytes; the source planes sit at row offsets `0/H/2H` of the
/// single allocation. The caller must have the shared context current. /// single allocation. The caller must have the shared context current.
pub fn copy_yuv444_to_device(src: &DeviceBuffer, dsts: [(CUdeviceptr, usize); 3]) -> Result<()> { /// `sync: false` enqueues without a CPU wait (stream-ordered consumers only — `src` must stay
/// valid until the downstream stream work completes; see [`copy_stream_handle`]).
pub fn copy_yuv444_to_device(
src: &DeviceBuffer,
dsts: [(CUdeviceptr, usize); 3],
sync: bool,
) -> Result<()> {
anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer"); anyhow::ensure!(src.yuv444, "copy_yuv444_to_device on a non-YUV444 buffer");
let w = src.width as usize; let w = src.width as usize;
let h = src.height as usize; let h = src.height as usize;
@@ -1221,12 +1273,13 @@ pub fn copy_yuv444_to_device(src: &DeviceBuffer, dsts: [(CUdeviceptr, usize); 3]
Height: h, Height: h,
..Default::default() ..Default::default()
}; };
// SAFETY: unsafe `copy_blocking` device→device copy; the caller must have the shared // SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared
// context current (documented). `&copy` is a live local outliving the synchronous call; // context current (documented). `&copy` is a live local outliving the enqueue;
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444` // `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits // checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
// both. Wrapper → live table. // both; `sync: false` shifts the source-lifetime obligation to the caller (documented
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? }; // above). Wrapper → live table.
unsafe { copy_issue(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
} }
Ok(()) Ok(())
} }