fix+perf(encode): clamp Vulkan CSC to source edge + cache dmabuf imports
Two refinements after the initial on-glass validation on RADV (780M): - Green padding bar at non-16-aligned heights (e.g. 1080 → coded 1088): the CSC compute shader read past the edge of the shorter source dmabuf for the 8 alignment-padding rows, producing undefined/green garbage that showed on a client rendering the coded frame. Clamp every source fetch to `textureSize-1` so padding rows duplicate the last real row (invisible, and the SPS conformance window still crops it for a compliant decoder). BT.709 conversion is byte-identical for in-bounds pixels. 5120x1440 (exactly aligned) was never affected. - Per-frame dmabuf import churn: the backend created + imported + destroyed a VkImage every frame (allocation jitter → stutter). PipeWire cycles a small fixed pool, so import each underlying buffer ONCE (keyed by st_dev/st_ino — each frame's fd is a fresh dup of the same buffer) and reuse it, matching the CUDA-path VkBridge. First import acquires from the foreign producer; cached re-reads keep queue ownership and use a plain visibility barrier. On-glass: ~3-6 imports per session then silent (was ~one per frame at 240 Hz), stutter gone at resolutions with headroom. Also adds a PF_SMOKE_W/H override to the headless smoke test to exercise the conformance-window crop path (ffprobe confirms coded 1088 → displayed 1080). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,15 +7,20 @@ layout(binding = 2, rg8) uniform writeonly image2D uvImg; // half-res UV (inte
|
||||
|
||||
float lumaY(vec3 c) { return 16.0/255.0 + 0.1826*c.r + 0.6142*c.g + 0.0620*c.b; }
|
||||
|
||||
// Source may be SMALLER than the coded (16-aligned) Y plane — e.g. 1080 source vs 1088 coded. Clamp
|
||||
// every fetch to the source edge so the alignment-padding rows duplicate the last real row instead
|
||||
// of reading out of bounds (undefined → green garbage that shows if a client ignores the SPS
|
||||
// conformance-window crop). `textureSize` gives the bound source's real extent.
|
||||
void main() {
|
||||
ivec2 sz = imageSize(yImg);
|
||||
ivec2 rmax = textureSize(rgb, 0) - 1;
|
||||
ivec2 uvc = ivec2(gl_GlobalInvocationID.xy);
|
||||
ivec2 p = uvc * 2;
|
||||
if (p.x >= sz.x || p.y >= sz.y) return;
|
||||
vec3 c00 = texelFetch(rgb, p, 0).rgb;
|
||||
vec3 c10 = texelFetch(rgb, p + ivec2(1, 0), 0).rgb;
|
||||
vec3 c01 = texelFetch(rgb, p + ivec2(0, 1), 0).rgb;
|
||||
vec3 c11 = texelFetch(rgb, p + ivec2(1, 1), 0).rgb;
|
||||
vec3 c00 = texelFetch(rgb, min(p, rmax), 0).rgb;
|
||||
vec3 c10 = texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb;
|
||||
vec3 c01 = texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb;
|
||||
vec3 c11 = texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb;
|
||||
imageStore(yImg, p, vec4(lumaY(c00), 0, 0, 1));
|
||||
imageStore(yImg, p + ivec2(1, 0), vec4(lumaY(c10), 0, 0, 1));
|
||||
imageStore(yImg, p + ivec2(0, 1), vec4(lumaY(c01), 0, 0, 1));
|
||||
|
||||
Binary file not shown.
@@ -13,9 +13,12 @@ use anyhow::{bail, Context, Result};
|
||||
use ash::vk;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::IntoRawFd;
|
||||
use std::os::fd::{AsRawFd, IntoRawFd};
|
||||
|
||||
const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM;
|
||||
/// Max resident dmabuf imports (comfortably above any PipeWire pool depth; imports alias existing
|
||||
/// buffers so this holds handles, not new allocations).
|
||||
const IMPORT_CACHE_CAP: usize = 16;
|
||||
// Prebuilt SPIR-V for the RGB→NV12 BT.709 compute CSC. Source is `rgb2yuv.comp` beside this file;
|
||||
// regenerate with `glslangValidator -V rgb2yuv.comp -o rgb2yuv.spv` after editing the shader.
|
||||
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
|
||||
@@ -81,6 +84,9 @@ pub struct VulkanVideoEncoder {
|
||||
// CPU-input staging (lazily sized)
|
||||
cpu_img: Option<(vk::Image, vk::DeviceMemory, vk::ImageView, vk::Format)>,
|
||||
cpu_stage: Option<(vk::Buffer, vk::DeviceMemory, u64)>,
|
||||
// Per-buffer dmabuf-import cache, keyed by (st_dev, st_ino) — PipeWire cycles a small fixed pool,
|
||||
// so each underlying buffer is imported ONCE and reused (no per-frame VkImage create/import/destroy).
|
||||
import_cache: Vec<(u64, u64, vk::Image, vk::DeviceMemory, vk::ImageView)>,
|
||||
|
||||
// --- bitstream + submit ---
|
||||
bs_buf: vk::Buffer,
|
||||
@@ -557,6 +563,7 @@ impl VulkanVideoEncoder {
|
||||
nv12_view,
|
||||
cpu_img: None,
|
||||
cpu_stage: None,
|
||||
import_cache: Vec::new(),
|
||||
bs_buf,
|
||||
bs_mem,
|
||||
bs_size,
|
||||
@@ -683,6 +690,43 @@ impl VulkanVideoEncoder {
|
||||
Ok((img, mem, view))
|
||||
}
|
||||
|
||||
/// Import a dmabuf, reusing a cached per-buffer import when the same underlying buffer recurs
|
||||
/// (PipeWire cycles a small fixed pool). Keyed by `(st_dev, st_ino)` because each `DmabufFrame`
|
||||
/// owns a fresh *dup* — a new fd number, same inode. Returns `(image, view, fresh)`; `fresh` is
|
||||
/// true only on a first import (caller uses UNDEFINED old-layout to preserve modifier-tiled data).
|
||||
unsafe fn import_cached(
|
||||
&mut self,
|
||||
d: &crate::capture::DmabufFrame,
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
) -> Result<(vk::Image, vk::ImageView, bool)> {
|
||||
let mut st: libc::stat = std::mem::zeroed();
|
||||
let key = if libc::fstat(d.fd.as_raw_fd(), &mut st) == 0 {
|
||||
(st.st_dev as u64, st.st_ino as u64)
|
||||
} else {
|
||||
// fstat failed → uncacheable; a per-frame-unique sentinel key never matches, so this
|
||||
// frame imports fresh (as before) but is still owned by the cache and freed on evict/Drop.
|
||||
(u64::MAX, self.enc_count)
|
||||
};
|
||||
if let Some(&(_, _, img, _, view)) = self.import_cache.iter().find(|e| (e.0, e.1) == key) {
|
||||
return Ok((img, view, false));
|
||||
}
|
||||
let (img, mem, view) = self.import_dmabuf(d, cw, ch)?;
|
||||
// Bound the cache; evict oldest (FIFO). A stable PipeWire pool never trips this in steady state
|
||||
// (all imports resident); it only cycles across a pool change (which also rebuilds the session).
|
||||
while self.import_cache.len() >= IMPORT_CACHE_CAP {
|
||||
let (_, _, oi, om, ov) = self.import_cache.remove(0);
|
||||
self.device.destroy_image_view(ov, None);
|
||||
self.device.destroy_image(oi, None);
|
||||
self.device.free_memory(om, None);
|
||||
}
|
||||
self.import_cache.push((key.0, key.1, img, mem, view));
|
||||
// Fires once per distinct pool buffer then goes quiet in steady state — the signal the cache
|
||||
// is hitting (a per-frame log here would mean inode keying failed and we're re-importing).
|
||||
tracing::debug!(resident = self.import_cache.len(), "vulkan-encode: imported a new dmabuf buffer");
|
||||
Ok((img, view, true))
|
||||
}
|
||||
|
||||
/// Reusable RGB image + staging buffer for software (CPU) capture; (re)created on format change.
|
||||
unsafe fn ensure_cpu_rgb(&mut self, fmt: vk::Format, bytes: &[u8]) -> Result<vk::ImageView> {
|
||||
let need = (self.width * self.height * 4) as u64;
|
||||
@@ -779,7 +823,6 @@ impl VulkanVideoEncoder {
|
||||
// ---- 2. RGB source -> compute_cmd: prep barriers + CSC + copy into nv12_src ----
|
||||
let cw = frame.width.min(w);
|
||||
let ch = frame.height.min(h_px);
|
||||
let mut temp_import: Option<(vk::Image, vk::DeviceMemory, vk::ImageView)> = None;
|
||||
let dev = self.device.clone(); // cheap handle clone -> lets us also call &mut self helpers
|
||||
dev.begin_command_buffer(
|
||||
self.compute_cmd,
|
||||
@@ -789,18 +832,33 @@ impl VulkanVideoEncoder {
|
||||
|
||||
let rgb_view = match &frame.payload {
|
||||
FramePayload::Dmabuf(d) => {
|
||||
let (img, mem, view) = self.import_dmabuf(d, frame.width, frame.height)?;
|
||||
temp_import = Some((img, mem, view));
|
||||
// acquire from the foreign (capture) domain; UNDEFINED preserves modifier-tiled data
|
||||
// Reuse the per-buffer import (PipeWire cycles a small pool) — no per-frame VkImage
|
||||
// create/import/destroy. The producer wrote new content out-of-band, so still acquire
|
||||
// from FOREIGN each frame; a fresh import starts UNDEFINED (preserves modifier-tiled
|
||||
// data), a cached one is already SHADER_READ_ONLY_OPTIMAL.
|
||||
let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?;
|
||||
// First import: acquire from the foreign producer (UNDEFINED preserves the modifier-tiled
|
||||
// bytes). Cached re-read: we still own it, so no queue-family transfer — just a visibility
|
||||
// barrier so the shader read sees the content the producer wrote out-of-band this frame
|
||||
// (single-GPU coherent; the capture layer guarantees the buffer is ready at hand-off).
|
||||
let (old, src_qf, dst_qf) = if fresh {
|
||||
(vk::ImageLayout::UNDEFINED, vk::QUEUE_FAMILY_FOREIGN_EXT, self.compute_family)
|
||||
} else {
|
||||
(
|
||||
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
|
||||
vk::QUEUE_FAMILY_IGNORED,
|
||||
vk::QUEUE_FAMILY_IGNORED,
|
||||
)
|
||||
};
|
||||
let acq = vk::ImageMemoryBarrier2::default()
|
||||
.src_stage_mask(vk::PipelineStageFlags2::NONE)
|
||||
.src_access_mask(vk::AccessFlags2::NONE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags2::COMPUTE_SHADER)
|
||||
.dst_access_mask(vk::AccessFlags2::SHADER_READ)
|
||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
||||
.old_layout(old)
|
||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
.src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT)
|
||||
.dst_queue_family_index(self.compute_family)
|
||||
.src_queue_family_index(src_qf)
|
||||
.dst_queue_family_index(dst_qf)
|
||||
.image(img)
|
||||
.subresource_range(color_range(0));
|
||||
dev.cmd_pipeline_barrier2(
|
||||
@@ -1244,12 +1302,6 @@ impl VulkanVideoEncoder {
|
||||
self.enc_count += 1;
|
||||
self.first_frame = false;
|
||||
self.force_kf = false;
|
||||
|
||||
if let Some((i, m, v)) = temp_import.take() {
|
||||
dev.destroy_image_view(v, None);
|
||||
dev.destroy_image(i, None);
|
||||
dev.free_memory(m, None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1322,6 +1374,11 @@ impl Drop for VulkanVideoEncoder {
|
||||
// memory, session params before session, session memory last).
|
||||
unsafe {
|
||||
let _ = self.device.device_wait_idle();
|
||||
for (_, _, img, mem, view) in std::mem::take(&mut self.import_cache) {
|
||||
self.device.destroy_image_view(view, None);
|
||||
self.device.destroy_image(img, None);
|
||||
self.device.free_memory(mem, None);
|
||||
}
|
||||
if let Some((i, m, v, _)) = self.cpu_img.take() {
|
||||
self.device.destroy_image_view(v, None);
|
||||
self.device.destroy_image(i, None);
|
||||
@@ -1682,7 +1739,8 @@ mod tests {
|
||||
#[test]
|
||||
#[ignore = "needs a real VK_KHR_video_encode_h265 device (run on the RADV host, not the build box)"]
|
||||
fn vulkan_smoke() {
|
||||
let (w, h) = (256u32, 256u32);
|
||||
let env_dim = |k: &str, d: u32| std::env::var(k).ok().and_then(|v| v.parse().ok()).unwrap_or(d);
|
||||
let (w, h) = (env_dim("PF_SMOKE_W", 256), env_dim("PF_SMOKE_H", 256));
|
||||
let mut enc = VulkanVideoEncoder::open(Codec::H265, w, h, 60, 10_000_000).expect("open");
|
||||
assert!(enc.caps().supports_rfi, "must advertise RFI");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user