feat(encode/nvenc): SPIR-V cursor blend over Vulkan-allocated input slots — retire the PTX kernels
A vendored PTX blob is JIT'd against the driver's ISA ceiling, so the cursor-blend module silently dies on drivers older than the generating toolkit (CUDA_ERROR_UNSUPPORTED_PTX_VERSION/INVALID_PTX, 222/218 — the KWin leg's invisible composite cursor on driver 595/CUDA 13.2 vs a CUDA 13.3 blob). SPIR-V has no such coupling, and the in-tree precedent already exists twice (vulkan_video's CSC blend, VkBridge's exportable OPAQUE_FD → cuImportExternalMemory bridge). New pf_zerocopy::vkslot::VkSlotBlend: the direct-SDK NVENC encoder now allocates its input ring as exportable Vulkan buffers CUDA-imports (same contiguous InputSurface layouts, pitch = row bytes rounded to 256), and the cursor composite is a compute dispatch over the cursor's rectangle (cursor_blend.comp, vendored .spv; spec-constant selects ARGB/NV12/YUV444; BT.709 limited, matching the retired .cu). The surface SSBO is uint[] with every invocation owning whole words — no 8-bit-storage device dependency. Cursor-bearing frames force the existing CPU-synced submit path so the CUDA copy → Vulkan dispatch (fence-waited) → NVENC encode ordering is CPU-established; cursorless frames keep the stream-ordered fast path untouched. Any bring-up/alloc/registration failure falls back wholesale to plain pitched CUDA surfaces (never a mixed or short ring): sessions always encode, composite mode just loses the cursor, warned once. cursor_blend.cu / cursor_blend.ptx and the CursorBlend PTX loader are deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,710 @@
|
||||
//! Vulkan-allocated NVENC input slots + the Vulkan compute cursor blend — the driver-portable
|
||||
//! replacement for the retired `cursor_blend.cu` PTX kernels (design: remote-desktop-sweep §8,
|
||||
//! Phase A). A vendored PTX blob is JIT'd against the driver's ISA ceiling, so it silently dies
|
||||
//! on drivers older than the generating toolkit (CUDA errors 222/218 on-glass — the KWin leg's
|
||||
//! invisible composite cursor); SPIR-V has no such coupling.
|
||||
//!
|
||||
//! ```text
|
||||
//! exportable VkBuffer ──vkGetMemoryFdKHR(OPAQUE_FD)──▶ cuImportExternalMemory ──▶ CUdeviceptr
|
||||
//! ▲ │ NVENC registers + encodes
|
||||
//! └── cursor_blend.comp dispatch (cursor rect only) ◀───┘ CUDA copies frames in
|
||||
//! ```
|
||||
//!
|
||||
//! The direct-SDK NVENC encoder allocates its input ring through [`VkSlotBlend::alloc_slot`]
|
||||
//! instead of `cuMemAllocPitch`: same contiguous layouts (`InputSurface` docs), but the memory is
|
||||
//! Vulkan external memory both APIs address. Per cursor-bearing frame the encoder CPU-syncs its
|
||||
//! CUDA copy, then [`VkSlotBlend::blend`] dispatches the compute blend over the cursor's
|
||||
//! rectangle and fence-waits — the same coherence ceremony [`super::vulkan::VkBridge`] ships for
|
||||
//! its CSC (fence-ordered cross-API access on NVIDIA, no queue-family transfer needed). Frames
|
||||
//! without a cursor never touch Vulkan, keeping the stream-ordered fast path intact.
|
||||
//!
|
||||
//! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite
|
||||
//! mode degrades to no cursor (warned once) — never a failed session.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::cuda::{self, CUdeviceptr};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use ash::vk;
|
||||
|
||||
/// Max cursor-overlay bitmap edge (px) — matches [`cuda::CURSOR_MAX`] and the capture-side clamp.
|
||||
pub const CURSOR_MAX: u32 = cuda::CURSOR_MAX;
|
||||
|
||||
/// The vendored SPIR-V for `cursor_blend.comp` (beside this file; rebuild with
|
||||
/// `glslc cursor_blend.comp -o cursor_blend.spv`).
|
||||
const CURSOR_SPV: &[u8] = include_bytes!("cursor_blend.spv");
|
||||
|
||||
/// NVENC input-surface layout — selects the spec-constant `MODE` pipeline and the allocation
|
||||
/// arithmetic (mirroring `InputSurface`'s contiguous layouts).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum SlotFormat {
|
||||
/// Packed 4-byte ARGB (NVENC byte order B,G,R,A): `pitch × height`.
|
||||
Argb,
|
||||
/// NV12: Y rows `[0, H)` + interleaved UV rows `[H, 3H/2)` under one pitch.
|
||||
Nv12,
|
||||
/// Planar YUV444: three full-res planes stacked at `pitch × height` intervals.
|
||||
Yuv444,
|
||||
}
|
||||
|
||||
impl SlotFormat {
|
||||
fn mode(self) -> u32 {
|
||||
match self {
|
||||
SlotFormat::Argb => 0,
|
||||
SlotFormat::Nv12 => 1,
|
||||
SlotFormat::Yuv444 => 2,
|
||||
}
|
||||
}
|
||||
fn row_bytes(self, width: u32) -> u64 {
|
||||
match self {
|
||||
SlotFormat::Argb => width as u64 * 4,
|
||||
SlotFormat::Nv12 | SlotFormat::Yuv444 => width as u64,
|
||||
}
|
||||
}
|
||||
fn rows(self, height: u32) -> u64 {
|
||||
match self {
|
||||
SlotFormat::Argb => height as u64,
|
||||
SlotFormat::Nv12 => height as u64 + (height as u64 / 2).max(1),
|
||||
SlotFormat::Yuv444 => height as u64 * 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the encoder holds per ring slot: the CUDA view it registers with NVENC plus the id it
|
||||
/// hands back to [`VkSlotBlend::blend`]. The backing Vulkan objects + CUDA mapping live in the
|
||||
/// [`VkSlotBlend`] (freed by [`free_slots`](VkSlotBlend::free_slots) / drop), so this is Copy —
|
||||
/// the encoder's ring keeps its existing shape.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct VkSlotRef {
|
||||
/// Device pointer NVENC registers (CUDA's mapping of the Vulkan memory).
|
||||
pub ptr: CUdeviceptr,
|
||||
/// Row stride in bytes (ours: row bytes rounded up to 256).
|
||||
pub pitch: usize,
|
||||
/// Luma rows (the plane-stride multiplier, as in `InputSurface`).
|
||||
pub height: u32,
|
||||
/// Index into the blend's slot table.
|
||||
pub id: usize,
|
||||
}
|
||||
|
||||
/// One allocated slot's backing objects, freed together in reverse order (CUDA mapping first).
|
||||
struct SlotAlloc {
|
||||
buffer: vk::Buffer,
|
||||
memory: vk::DeviceMemory,
|
||||
/// CUDA's import of the exported OPAQUE_FD — must drop BEFORE the Vulkan memory is freed.
|
||||
cuda: cuda::ExternalDmabuf,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// 28-byte push-constant block matching `cursor_blend.comp`'s `Push`.
|
||||
#[repr(C)]
|
||||
struct Push {
|
||||
pitch: u32,
|
||||
surf_w: u32,
|
||||
surf_h: u32,
|
||||
cur_w: u32,
|
||||
cur_h: u32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
}
|
||||
|
||||
pub struct VkSlotBlend {
|
||||
_entry: ash::Entry,
|
||||
instance: ash::Instance,
|
||||
device: ash::Device,
|
||||
ext_fd: ash::khr::external_memory_fd::Device,
|
||||
queue: vk::Queue,
|
||||
cmd_pool: vk::CommandPool,
|
||||
cmd: vk::CommandBuffer,
|
||||
fence: vk::Fence,
|
||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||
shader: vk::ShaderModule,
|
||||
desc_layout: vk::DescriptorSetLayout,
|
||||
pipe_layout: vk::PipelineLayout,
|
||||
desc_pool: vk::DescriptorPool,
|
||||
desc_set: vk::DescriptorSet,
|
||||
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
|
||||
pipelines: [vk::Pipeline; 3],
|
||||
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
|
||||
cur_buf: vk::Buffer,
|
||||
cur_mem: vk::DeviceMemory,
|
||||
cur_map: *mut u8,
|
||||
slots: Vec<SlotAlloc>,
|
||||
}
|
||||
|
||||
// SAFETY: raw Vulkan handles + a persistently-mapped pointer, all uniquely owned by this struct
|
||||
// and destroyed exactly once in `Drop`; used from the encoder thread but moved with it. `Send`
|
||||
// only (not `Sync`), matching the single-thread use — transferring opaque handles cannot dangle.
|
||||
unsafe impl Send for VkSlotBlend {}
|
||||
|
||||
impl VkSlotBlend {
|
||||
/// Bring up the device + blend pipelines. Requires the CUDA shared context (the encoder's) to
|
||||
/// be established; picks the NVIDIA physical device (the NVENC path is NVIDIA by definition).
|
||||
pub fn new() -> Result<VkSlotBlend> {
|
||||
// SAFETY: standard ash bring-up, same shape as `VkBridge::new` — every call is `unsafe`
|
||||
// only because ash cannot statically verify handle/CreateInfo validity. Every
|
||||
// `*CreateInfo`/`AllocateInfo` is built from locals that live for the duration of the
|
||||
// synchronous call reading them; every handle passed was created and `?`-checked in this
|
||||
// same function. Shares nothing across threads.
|
||||
unsafe {
|
||||
let entry = ash::Entry::load().context("load libvulkan")?;
|
||||
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_1);
|
||||
let instance = entry
|
||||
.create_instance(
|
||||
&vk::InstanceCreateInfo::default().application_info(&app),
|
||||
None,
|
||||
)
|
||||
.context("vkCreateInstance")?;
|
||||
let phys = match instance
|
||||
.enumerate_physical_devices()
|
||||
.context("enumerate GPUs")?
|
||||
.into_iter()
|
||||
.find(|&p| instance.get_physical_device_properties(p).vendor_id == 0x10DE)
|
||||
{
|
||||
Some(p) => p,
|
||||
None => {
|
||||
instance.destroy_instance(None);
|
||||
return Err(anyhow!("no NVIDIA Vulkan device"));
|
||||
}
|
||||
};
|
||||
let mem_props = instance.get_physical_device_memory_properties(phys);
|
||||
let qf = match instance
|
||||
.get_physical_device_queue_family_properties(phys)
|
||||
.iter()
|
||||
.position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
|
||||
{
|
||||
Some(i) => i as u32,
|
||||
None => {
|
||||
instance.destroy_instance(None);
|
||||
return Err(anyhow!("no compute-capable queue family"));
|
||||
}
|
||||
};
|
||||
let prio = [1.0f32];
|
||||
let qci = [vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(qf)
|
||||
.queue_priorities(&prio)];
|
||||
let exts = [ash::khr::external_memory_fd::NAME.as_ptr()];
|
||||
let device = match instance.create_device(
|
||||
phys,
|
||||
&vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(&qci)
|
||||
.enabled_extension_names(&exts),
|
||||
None,
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
instance.destroy_instance(None);
|
||||
return Err(e).context("vkCreateDevice (external_memory_fd supported?)");
|
||||
}
|
||||
};
|
||||
// From here teardown-on-error goes through `destroy_partial`, which tolerates null
|
||||
// handles — build everything into an incrementally-filled struct.
|
||||
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
|
||||
let queue = device.get_device_queue(qf, 0);
|
||||
let mut me = VkSlotBlend {
|
||||
_entry: entry,
|
||||
instance,
|
||||
device,
|
||||
ext_fd,
|
||||
queue,
|
||||
cmd_pool: vk::CommandPool::null(),
|
||||
cmd: vk::CommandBuffer::null(),
|
||||
fence: vk::Fence::null(),
|
||||
mem_props,
|
||||
shader: vk::ShaderModule::null(),
|
||||
desc_layout: vk::DescriptorSetLayout::null(),
|
||||
pipe_layout: vk::PipelineLayout::null(),
|
||||
desc_pool: vk::DescriptorPool::null(),
|
||||
desc_set: vk::DescriptorSet::null(),
|
||||
pipelines: [vk::Pipeline::null(); 3],
|
||||
cur_buf: vk::Buffer::null(),
|
||||
cur_mem: vk::DeviceMemory::null(),
|
||||
cur_map: std::ptr::null_mut(),
|
||||
slots: Vec::new(),
|
||||
};
|
||||
me.init_objects(qf).inspect_err(|_| {
|
||||
// `Drop` runs the same teardown and tolerates the nulls left by a partial init.
|
||||
})?;
|
||||
tracing::info!(
|
||||
"Vulkan slot blend ready (exportable NVENC inputs + SPIR-V cursor blend)"
|
||||
);
|
||||
Ok(me)
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-device objects: command machinery, cursor staging, descriptor + pipelines.
|
||||
fn init_objects(&mut self, qf: u32) -> Result<()> {
|
||||
// SAFETY: same contract as `new` — ash calls on the live `self.device` with builder infos
|
||||
// from locals outliving each synchronous call; created handles are stored into `self`
|
||||
// immediately so the caller's `Drop` frees them on any later failure.
|
||||
unsafe {
|
||||
let d = &self.device;
|
||||
self.cmd_pool = d
|
||||
.create_command_pool(
|
||||
&vk::CommandPoolCreateInfo::default()
|
||||
.queue_family_index(qf)
|
||||
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
|
||||
None,
|
||||
)
|
||||
.context("create command pool")?;
|
||||
self.cmd = d
|
||||
.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(self.cmd_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(1),
|
||||
)
|
||||
.context("allocate command buffer")?[0];
|
||||
self.fence = d
|
||||
.create_fence(&vk::FenceCreateInfo::default(), None)
|
||||
.context("create fence")?;
|
||||
|
||||
// Cursor staging: host-visible+coherent SSBO, persistently mapped.
|
||||
let cur_size = (CURSOR_MAX * CURSOR_MAX * 4) as u64;
|
||||
self.cur_buf = d
|
||||
.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(cur_size)
|
||||
.usage(vk::BufferUsageFlags::STORAGE_BUFFER),
|
||||
None,
|
||||
)
|
||||
.context("create cursor buffer")?;
|
||||
let reqs = d.get_buffer_memory_requirements(self.cur_buf);
|
||||
let mem_type = self
|
||||
.memory_type(
|
||||
reqs.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)
|
||||
.context("cursor buffer memory type")?;
|
||||
self.cur_mem = d
|
||||
.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(reqs.size)
|
||||
.memory_type_index(mem_type),
|
||||
None,
|
||||
)
|
||||
.context("allocate cursor memory")?;
|
||||
d.bind_buffer_memory(self.cur_buf, self.cur_mem, 0)
|
||||
.context("bind cursor memory")?;
|
||||
self.cur_map = d
|
||||
.map_memory(self.cur_mem, 0, cur_size, vk::MemoryMapFlags::empty())
|
||||
.context("map cursor memory")? as *mut u8;
|
||||
|
||||
// Descriptor set: binding 0 = surface SSBO (rebound per blend), 1 = cursor SSBO.
|
||||
let bindings = [
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(0)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||
];
|
||||
self.desc_layout = d
|
||||
.create_descriptor_set_layout(
|
||||
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
|
||||
None,
|
||||
)
|
||||
.context("create descriptor layout")?;
|
||||
let pc = [vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE)
|
||||
.size(std::mem::size_of::<Push>() as u32)];
|
||||
let dl = [self.desc_layout];
|
||||
self.pipe_layout = d
|
||||
.create_pipeline_layout(
|
||||
&vk::PipelineLayoutCreateInfo::default()
|
||||
.set_layouts(&dl)
|
||||
.push_constant_ranges(&pc),
|
||||
None,
|
||||
)
|
||||
.context("create pipeline layout")?;
|
||||
let pool_sizes = [vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(2)];
|
||||
self.desc_pool = d
|
||||
.create_descriptor_pool(
|
||||
&vk::DescriptorPoolCreateInfo::default()
|
||||
.max_sets(1)
|
||||
.pool_sizes(&pool_sizes),
|
||||
None,
|
||||
)
|
||||
.context("create descriptor pool")?;
|
||||
let dls = [self.desc_layout];
|
||||
self.desc_set = d
|
||||
.allocate_descriptor_sets(
|
||||
&vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(self.desc_pool)
|
||||
.set_layouts(&dls),
|
||||
)
|
||||
.context("allocate descriptor set")?[0];
|
||||
|
||||
// The shader + one pipeline per MODE (spec constant 0).
|
||||
if CURSOR_SPV.len() % 4 != 0 {
|
||||
anyhow::bail!("cursor_blend.spv is not word-aligned");
|
||||
}
|
||||
let words: Vec<u32> = CURSOR_SPV
|
||||
.chunks_exact(4)
|
||||
.map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect();
|
||||
self.shader = d
|
||||
.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None)
|
||||
.context("create blend shader module")?;
|
||||
for mode in 0u32..3 {
|
||||
let entries = [vk::SpecializationMapEntry::default()
|
||||
.constant_id(0)
|
||||
.offset(0)
|
||||
.size(4)];
|
||||
let data = mode.to_le_bytes();
|
||||
let spec = vk::SpecializationInfo::default()
|
||||
.map_entries(&entries)
|
||||
.data(&data);
|
||||
let stage = vk::PipelineShaderStageCreateInfo::default()
|
||||
.stage(vk::ShaderStageFlags::COMPUTE)
|
||||
.module(self.shader)
|
||||
.name(c"main")
|
||||
.specialization_info(&spec);
|
||||
let info = [vk::ComputePipelineCreateInfo::default()
|
||||
.stage(stage)
|
||||
.layout(self.pipe_layout)];
|
||||
let p = d
|
||||
.create_compute_pipelines(vk::PipelineCache::null(), &info, None)
|
||||
.map_err(|(_, e)| e)
|
||||
.context("create blend pipeline")?[0];
|
||||
self.pipelines[mode as usize] = p;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn memory_type(&self, type_bits: u32, flags: vk::MemoryPropertyFlags) -> Result<u32> {
|
||||
(0..self.mem_props.memory_type_count)
|
||||
.find(|&i| {
|
||||
type_bits & (1 << i) != 0
|
||||
&& self.mem_props.memory_types[i as usize]
|
||||
.property_flags
|
||||
.contains(flags)
|
||||
})
|
||||
.ok_or_else(|| anyhow!("no memory type for flags {flags:?}"))
|
||||
}
|
||||
|
||||
/// Allocate one NVENC input slot as exportable Vulkan memory mapped into CUDA. Layout matches
|
||||
/// `InputSurface` (contiguous planes under one pitch); pitch = row bytes rounded to 256.
|
||||
pub fn alloc_slot(&mut self, fmt: SlotFormat, width: u32, height: u32) -> Result<VkSlotRef> {
|
||||
let pitch = (fmt.row_bytes(width) + 255) & !255;
|
||||
let size = pitch * fmt.rows(height);
|
||||
// SAFETY: exportable-buffer allocation, the exact `VkBridge::ensure_dst` incantation:
|
||||
// `ExternalMemoryBufferCreateInfo`/`ExportMemoryAllocateInfo` declare OPAQUE_FD,
|
||||
// `MemoryDedicatedAllocateInfo` ties the memory to the buffer; every info is a local
|
||||
// outliving its synchronous call and every failure path destroys the objects created so
|
||||
// far exactly once. `get_memory_fd` hands us an fd that `import_owned_fd` either adopts
|
||||
// (driver owns it) or closes on failure.
|
||||
unsafe {
|
||||
let d = &self.device;
|
||||
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
||||
let buffer = d
|
||||
.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(size)
|
||||
.usage(vk::BufferUsageFlags::STORAGE_BUFFER)
|
||||
.push_next(&mut ext_info),
|
||||
None,
|
||||
)
|
||||
.context("create slot buffer")?;
|
||||
let reqs = d.get_buffer_memory_requirements(buffer);
|
||||
let mem_type = match self
|
||||
.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
d.destroy_buffer(buffer, None);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut export = vk::ExportMemoryAllocateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
||||
let memory = match d.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(reqs.size)
|
||||
.memory_type_index(mem_type)
|
||||
.push_next(&mut export)
|
||||
.push_next(&mut dedicated),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
d.destroy_buffer(buffer, None);
|
||||
return Err(e).context("allocate exportable slot memory");
|
||||
}
|
||||
};
|
||||
if let Err(e) = d.bind_buffer_memory(buffer, memory, 0) {
|
||||
d.free_memory(memory, None);
|
||||
d.destroy_buffer(buffer, None);
|
||||
return Err(e).context("bind slot memory");
|
||||
}
|
||||
let fd = match self.ext_fd.get_memory_fd(
|
||||
&vk::MemoryGetFdInfoKHR::default()
|
||||
.memory(memory)
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
|
||||
) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
d.free_memory(memory, None);
|
||||
d.destroy_buffer(buffer, None);
|
||||
return Err(e).context("vkGetMemoryFdKHR(slot)");
|
||||
}
|
||||
};
|
||||
let ext = match cuda::ExternalDmabuf::import_owned_fd(fd, reqs.size) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
d.free_memory(memory, None);
|
||||
d.destroy_buffer(buffer, None);
|
||||
return Err(e).context("cuImportExternalMemory(slot OPAQUE_FD)");
|
||||
}
|
||||
};
|
||||
let r = VkSlotRef {
|
||||
ptr: ext.ptr,
|
||||
pitch: pitch as usize,
|
||||
height,
|
||||
id: self.slots.len(),
|
||||
};
|
||||
self.slots.push(SlotAlloc {
|
||||
buffer,
|
||||
memory,
|
||||
cuda: ext,
|
||||
size,
|
||||
});
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
|
||||
/// Free every allocated slot (encoder teardown, alongside its ring clear). CUDA mappings drop
|
||||
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK
|
||||
/// objects explicitly here).
|
||||
pub fn free_slots(&mut self) {
|
||||
for s in self.slots.drain(..) {
|
||||
drop(s.cuda); // CUDA's view of the memory goes first
|
||||
// SAFETY: `buffer`/`memory` were created in `alloc_slot`, are uniquely owned by the
|
||||
// drained `SlotAlloc`, and are destroyed exactly once here. No queue work can be
|
||||
// in flight: every `blend` fence-waits before returning.
|
||||
unsafe {
|
||||
self.device.destroy_buffer(s.buffer, None);
|
||||
self.device.free_memory(s.memory, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the cursor RGBA (`cw*ch*4`, tight rows) into the mapped staging buffer. Call only
|
||||
/// when the bitmap changes; position moves are push constants.
|
||||
pub fn upload_cursor(&mut self, rgba: &[u8], cw: u32, ch: u32) {
|
||||
let cw = cw.min(CURSOR_MAX);
|
||||
let ch = ch.min(CURSOR_MAX);
|
||||
let len = (cw * ch * 4) as usize;
|
||||
let len = len.min(rgba.len());
|
||||
// SAFETY: `cur_map` is the live persistent mapping of the CURSOR_MAX²·4 host-coherent
|
||||
// allocation (created in `init_objects`, unmapped only in `Drop`); `len` is clamped to
|
||||
// both the source slice and the buffer capacity. No blend is in flight (every `blend`
|
||||
// fence-waits before returning), so no GPU read races this host write.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(rgba.as_ptr(), self.cur_map, len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend the uploaded cursor into `slot` at `(ox, oy)`: record, submit, fence-wait. The
|
||||
/// caller has CPU-synced its CUDA frame copy first; the fence wait makes the shader's writes
|
||||
/// visible to the subsequent NVENC encode (the `VkBridge` precedent: fence-ordered cross-API
|
||||
/// access, no queue-family transfer — NVIDIA-only path).
|
||||
#[allow(clippy::too_many_arguments)] // surface geometry + cursor rect — unpacked kernel args
|
||||
pub fn blend_ref(
|
||||
&mut self,
|
||||
slot: &VkSlotRef,
|
||||
fmt: SlotFormat,
|
||||
surf_w: u32,
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
) -> Result<()> {
|
||||
let alloc = self
|
||||
.slots
|
||||
.get(slot.id)
|
||||
.ok_or_else(|| anyhow!("bad slot id {}", slot.id))?;
|
||||
let cw = cw.min(CURSOR_MAX);
|
||||
let ch = ch.min(CURSOR_MAX);
|
||||
if cw == 0 || ch == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let push = Push {
|
||||
pitch: slot.pitch as u32,
|
||||
surf_w,
|
||||
surf_h: slot.height,
|
||||
cur_w: cw,
|
||||
cur_h: ch,
|
||||
ox,
|
||||
oy,
|
||||
};
|
||||
// Dispatch geometry (must match cursor_blend.comp): ARGB = per cursor px; NV12/YUV444 =
|
||||
// per word-aligned 4-px span × (2-row blocks | rows).
|
||||
let (gx, gy) = match fmt {
|
||||
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
|
||||
_ => {
|
||||
let x0 = (ox >> 2) << 2;
|
||||
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
|
||||
let rows = match fmt {
|
||||
SlotFormat::Nv12 => ch.div_ceil(2),
|
||||
_ => ch,
|
||||
};
|
||||
(spans.div_ceil(8), rows.div_ceil(8))
|
||||
}
|
||||
};
|
||||
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The descriptor
|
||||
// update is safe because no prior submission is in flight (every blend fence-waits and
|
||||
// the fence is reset before reuse). Buffer infos and barrier structs are locals outliving
|
||||
// their synchronous calls. The dispatch's shader accesses stay in-bounds by the shader's
|
||||
// own guards (surfW/surfH/curW/curH from `push`) against the slot allocation sized in
|
||||
// `alloc_slot` for exactly that geometry.
|
||||
unsafe {
|
||||
let d = &self.device;
|
||||
let surf_info = [vk::DescriptorBufferInfo::default()
|
||||
.buffer(alloc.buffer)
|
||||
.offset(0)
|
||||
.range(alloc.size)];
|
||||
let cur_info = [vk::DescriptorBufferInfo::default()
|
||||
.buffer(self.cur_buf)
|
||||
.offset(0)
|
||||
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
|
||||
let writes = [
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(self.desc_set)
|
||||
.dst_binding(0)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&surf_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(self.desc_set)
|
||||
.dst_binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&cur_info),
|
||||
];
|
||||
d.update_descriptor_sets(&writes, &[]);
|
||||
|
||||
d.begin_command_buffer(
|
||||
self.cmd,
|
||||
&vk::CommandBufferBeginInfo::default()
|
||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||
)
|
||||
.context("begin blend cmd")?;
|
||||
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
|
||||
// the shader (external-memory coherence ceremony; NVIDIA honors this with the fence
|
||||
// ordering alone, the barrier is the spec-shaped belt-and-braces).
|
||||
let acquire = [vk::MemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
|
||||
d.cmd_pipeline_barrier(
|
||||
self.cmd,
|
||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&acquire,
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
d.cmd_bind_pipeline(
|
||||
self.cmd,
|
||||
vk::PipelineBindPoint::COMPUTE,
|
||||
self.pipelines[fmt.mode() as usize],
|
||||
);
|
||||
d.cmd_bind_descriptor_sets(
|
||||
self.cmd,
|
||||
vk::PipelineBindPoint::COMPUTE,
|
||||
self.pipe_layout,
|
||||
0,
|
||||
&[self.desc_set],
|
||||
&[],
|
||||
);
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
(&push as *const Push) as *const u8,
|
||||
std::mem::size_of::<Push>(),
|
||||
);
|
||||
d.cmd_push_constants(
|
||||
self.cmd,
|
||||
self.pipe_layout,
|
||||
vk::ShaderStageFlags::COMPUTE,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
d.cmd_dispatch(self.cmd, gx.max(1), gy.max(1), 1);
|
||||
// Release the shader's writes so the post-fence CUDA/NVENC reads see them.
|
||||
let release = [vk::MemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
|
||||
d.cmd_pipeline_barrier(
|
||||
self.cmd,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||
vk::DependencyFlags::empty(),
|
||||
&release,
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
d.end_command_buffer(self.cmd).context("end blend cmd")?;
|
||||
let cmds = [self.cmd];
|
||||
let submit = [vk::SubmitInfo::default().command_buffers(&cmds)];
|
||||
d.queue_submit(self.queue, &submit, self.fence)
|
||||
.context("submit blend")?;
|
||||
let r = d.wait_for_fences(&[self.fence], true, 1_000_000_000);
|
||||
d.reset_fences(&[self.fence]).ok();
|
||||
r.context("blend fence wait")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VkSlotBlend {
|
||||
fn drop(&mut self) {
|
||||
self.free_slots();
|
||||
// SAFETY: every handle below was created in `new`/`init_objects` (or is null from a
|
||||
// partial init — Vulkan destroy/free calls are defined no-ops on null handles) and is
|
||||
// uniquely owned; each is destroyed exactly once here, pipelines/layouts/pools before the
|
||||
// device, the device before the instance. No work is in flight (`blend` fence-waits).
|
||||
unsafe {
|
||||
let d = &self.device;
|
||||
for p in self.pipelines {
|
||||
if p != vk::Pipeline::null() {
|
||||
d.destroy_pipeline(p, None);
|
||||
}
|
||||
}
|
||||
if self.shader != vk::ShaderModule::null() {
|
||||
d.destroy_shader_module(self.shader, None);
|
||||
}
|
||||
if self.desc_pool != vk::DescriptorPool::null() {
|
||||
d.destroy_descriptor_pool(self.desc_pool, None);
|
||||
}
|
||||
if self.pipe_layout != vk::PipelineLayout::null() {
|
||||
d.destroy_pipeline_layout(self.pipe_layout, None);
|
||||
}
|
||||
if self.desc_layout != vk::DescriptorSetLayout::null() {
|
||||
d.destroy_descriptor_set_layout(self.desc_layout, None);
|
||||
}
|
||||
if !self.cur_map.is_null() {
|
||||
d.unmap_memory(self.cur_mem);
|
||||
}
|
||||
if self.cur_buf != vk::Buffer::null() {
|
||||
d.destroy_buffer(self.cur_buf, None);
|
||||
}
|
||||
if self.cur_mem != vk::DeviceMemory::null() {
|
||||
d.free_memory(self.cur_mem, None);
|
||||
}
|
||||
if self.fence != vk::Fence::null() {
|
||||
d.destroy_fence(self.fence, None);
|
||||
}
|
||||
if self.cmd_pool != vk::CommandPool::null() {
|
||||
d.destroy_command_pool(self.cmd_pool, None);
|
||||
}
|
||||
d.destroy_device(None);
|
||||
self.instance.destroy_instance(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user