fix(zerocopy/vkslot): a timed-out blend can't corrupt its slot, and NV12 cursor chroma sits on the grid

Two [GPU]-class defects from design/pf-zerocopy-sweep-handoff.md; the code
is compile- and unit-verified, the visual halves still owe on-glass time.

- blend_ref reused a fence and command buffer after a wait timeout: on
  TIMEOUT it reset a fence that still had a pending signal and re-recorded
  a command buffer the GPU might still be executing — reached exactly in
  the contended case the CPU-synced path exists for (no timeline export +
  visible cursor + a >1 s GPU stall). A failed wait now drains the device
  before the reset (the VkBridge::import_linear precedent), and free_slots
  quiesces unconditionally instead of only when a timeline exists, so
  teardown after a timed-out sync blend no longer frees objects an
  outstanding submission references. (C1)

- cursor_blend.comp anchored NV12 chroma blocks to the cursor's oy, not
  the surface chroma grid: for odd oy every UV sample averaged luma rows
  one below the rows it covers (a one-row colour fringe on the cursor's
  edges, ~half of all cursor positions) and the cursor's last row's chroma
  was never written. Block rows now anchor to the chroma grid the same way
  spans anchor to the word grid — y0 = floor(oy/2)*2, per-row cy guards
  for the straddle block — and blend_geometry counts blocks from the same
  anchor (one extra block when oy is odd; covered by new tests, including
  negative oy). cursor_blend.spv rebuilt (glslangValidator, spirv-val
  clean, drift gate passes). (C6)

Owed on-glass: a stalled-GPU cursor session for C1; an odd-oy cursor
colour check for C6 (subtle fringe on the cursor's top/bottom edge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 13:01:46 +02:00
co-authored by Claude Fable 5
parent fa083f50d3
commit e92a0aaa00
3 changed files with 56 additions and 18 deletions
+21 -13
View File
@@ -15,7 +15,9 @@
// invocation exclusively owns the 32-bit words it read-modify-writes. ARGB: one invocation per // invocation exclusively owns the 32-bit words it read-modify-writes. ARGB: one invocation per
// cursor pixel = one word. NV12/YUV444: one invocation per WORD-ALIGNED 4-px luma span (per two // cursor pixel = one word. NV12/YUV444: one invocation per WORD-ALIGNED 4-px luma span (per two
// rows for NV12, whose 2 chroma bytes-pairs land in one exclusive word). Spans are aligned to the // rows for NV12, whose 2 chroma bytes-pairs land in one exclusive word). Spans are aligned to the
// SURFACE, not the cursor, so neighbouring invocations never share a word even at odd `ox`. // SURFACE, not the cursor, so neighbouring invocations never share a word even at odd `ox`; NV12
// block rows are likewise anchored to the surface chroma grid (even rows), so each UV sample's
// 2x2 footprint is exactly the luma rows it averages, at any `oy`.
// //
// Rebuild: glslangValidator -V cursor_blend.comp -o cursor_blend.spv (vendored beside this // Rebuild: glslangValidator -V cursor_blend.comp -o cursor_blend.spv (vendored beside this
// file; or glslc — CI diffs the disassembly against this source) // file; or glslc — CI diffs the disassembly against this source)
@@ -112,15 +114,20 @@ void main() {
return; return;
} }
// NV12: rows walk 2-row luma blocks (row = block row). The span's 4 luma px × 2 rows are // NV12: rows walk 2-row luma blocks anchored to the SURFACE chroma grid (block top = an even
// exclusive words; its 2 chroma samples (4 bytes) are one exclusive word. // surface row), the same way spans are anchored to the surface word grid in x. Anchoring to
int base_cy = row * 2; // the cursor's oy instead put every chroma sample one luma row high whenever oy was odd —
if (base_cy >= int(pc.curH)) return; // and never wrote the cursor's last row's chroma at all. y0 = floor(oy/2)*2; the arithmetic
// Luma: 4 px × 2 rows. // shift keeps that floor for negative oy. blend_geometry counts blocks from this same anchor.
int y0 = (pc.oy >> 1) << 1;
int py_top = y0 + row * 2; // this block's top luma row (even by construction)
if (py_top >= pc.oy + int(pc.curH)) return; // dispatch-padding block below the cursor
// Luma: 4 px × 2 rows. The first block can start one row above the cursor (odd oy) — the
// per-row cy guard skips that row.
for (int j = 0; j < 2; j++) { for (int j = 0; j < 2; j++) {
int cy = base_cy + j; int py = py_top + j;
int py = pc.oy + cy; int cy = py - pc.oy;
if (cy >= int(pc.curH) || py < 0 || py >= int(pc.surfH)) continue; if (cy < 0 || cy >= int(pc.curH) || py < 0 || py >= int(pc.surfH)) continue;
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
int px = px0 + i; int px = px0 + i;
int cx = px - pc.ox; int cx = px - pc.ox;
@@ -132,11 +139,12 @@ void main() {
} }
} }
// Chroma: two UV samples covering the span's 2x2 blocks, alpha-weighted like the .cu kernel. // Chroma: two UV samples covering the span's 2x2 blocks, alpha-weighted like the .cu kernel.
// The UV plane starts at row surfH; sample (uvx, uvy) lives at uv_base + uvy*pitch + uvx*2. // Because py_top sits on the chroma grid, sample uvy covers exactly luma rows py_top and
// py_top+1 — the rows averaged below. The UV plane starts at row surfH; sample (uvx, uvy)
// lives at uv_base + uvy*pitch + uvx*2.
// Guard: only spans whose px0 is 4-aligned own their chroma word (px0 is by construction). // Guard: only spans whose px0 is 4-aligned own their chroma word (px0 is by construction).
int py_top = pc.oy + base_cy;
int uvy = py_top >> 1; int uvy = py_top >> 1;
if (py_top < 0 || uvy < 0 || uvy * 2 >= int(pc.surfH)) return; if (py_top < 0 || uvy * 2 >= int(pc.surfH)) return;
uint uv_base = pc.pitch * pc.surfH; uint uv_base = pc.pitch * pc.surfH;
for (int hf = 0; hf < 2; hf++) { for (int hf = 0; hf < 2; hf++) {
// Each hf = one 2x2 luma block = one UV sample (2 bytes). // Each hf = one 2x2 luma block = one UV sample (2 bytes).
@@ -150,7 +158,7 @@ void main() {
int px = bx + i; int px = bx + i;
int py = py_top + j; int py = py_top + j;
int cx = px - pc.ox; int cx = px - pc.ox;
int cy = base_cy + j; int cy = py - pc.oy;
if (px < 0 || py < 0 || px >= int(pc.surfW) || py >= int(pc.surfH)) continue; if (px < 0 || py < 0 || px >= int(pc.surfW) || py >= int(pc.surfH)) continue;
uvec4 s = cursor_px(cx, cy); uvec4 s = cursor_px(cx, cy);
if (s.a == 0u) continue; if (s.a == 0u) continue;
Binary file not shown.
+35 -5
View File
@@ -660,10 +660,11 @@ impl VkSlotBlend {
pub fn free_slots(&mut self) { pub fn free_slots(&mut self) {
// Ordered blends return with their work still on the queue — quiesce before freeing the // Ordered blends return with their work still on the queue — quiesce before freeing the
// buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission // buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission
// whose CUDA copy-done signal never fired is still covered; this tiny device idles in // whose CUDA copy-done signal never fired is still covered. CPU-synced blends normally
// microseconds outside that pathological case. CPU-synced blends need nothing (they // fence-wait before returning, but a blend whose wait TIMED OUT propagated an error with
// fence-wait before returning), so a `None` timeline skips it. // its submission still executing so the drain runs unconditionally (it idles in
if self.timeline.is_some() && !self.slots.is_empty() { // microseconds on this tiny device outside the pathological cases it exists for).
if !self.slots.is_empty() {
// SAFETY: single-threaded owner; no other thread touches this device or its queue. // SAFETY: single-threaded owner; no other thread touches this device or its queue.
unsafe { unsafe {
let _ = self.device.device_wait_idle(); let _ = self.device.device_wait_idle();
@@ -773,7 +774,14 @@ impl VkSlotBlend {
let x0 = (ox >> 2) << 2; let x0 = (ox >> 2) << 2;
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32; let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
let rows = match fmt { let rows = match fmt {
SlotFormat::Nv12 => ch.div_ceil(2), SlotFormat::Nv12 => {
// 2-row blocks anchored to the SURFACE chroma grid (cursor_blend.comp
// derives the same y0): count the blocks covering luma rows
// [oy, oy+ch) — one more than ch/2 when oy is odd.
let first = oy.div_euclid(2);
let last = (oy + ch as i32 - 1).div_euclid(2);
(last - first + 1) as u32
}
_ => ch, _ => ch,
}; };
(spans.div_ceil(8), rows.div_ceil(8)) (spans.div_ceil(8), rows.div_ceil(8))
@@ -906,6 +914,14 @@ impl VkSlotBlend {
d.queue_submit(self.queue, &submit, self.fence) d.queue_submit(self.queue, &submit, self.fence)
.context("submit blend")?; .context("submit blend")?;
let r = d.wait_for_fences(&[self.fence], true, 1_000_000_000); let r = d.wait_for_fences(&[self.fence], true, 1_000_000_000);
if r.is_err() {
// TIMEOUT (or device loss): the submission may still be executing. Resetting a
// fence with a pending signal and re-recording this slot's command buffer next
// frame would race the GPU — drain the device first (what
// `VkBridge::import_linear` does on a failed wait). On this tiny device the
// idle completes in microseconds once the stall clears.
let _ = d.device_wait_idle();
}
d.reset_fences(&[self.fence]).ok(); d.reset_fences(&[self.fence]).ok();
r.context("blend fence wait")?; r.context("blend fence wait")?;
} }
@@ -1127,4 +1143,18 @@ mod tests {
assert_eq!(push.ox, -5, "push constants carry the true origin"); assert_eq!(push.ox, -5, "push constants carry the true origin");
assert_eq!(gx, 2, "9 spans from the floor-aligned start → 2 groups"); assert_eq!(gx, 2, "9 spans from the floor-aligned start → 2 groups");
} }
/// NV12 block rows anchor to the SURFACE chroma grid, so an odd `oy` needs one extra block
/// to reach the cursor's last luma row (the cursor-anchored count left that row's chroma
/// unwritten). Negative odd `oy` floors the same way (`div_euclid`).
#[test]
fn odd_oy_nv12_adds_the_straddle_block() {
let (_, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, 8).unwrap();
assert_eq!(gy, 2, "even oy: 16 chroma-grid blocks → 2 groups");
let (_, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, 7).unwrap();
assert_eq!(gy, 3, "odd oy: 17 blocks cover luma rows 7..39 → 3 groups");
let (push, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, -3).unwrap();
assert_eq!(push.oy, -3, "push constants carry the true origin");
assert_eq!(gy, 3, "blocks -4..30 in steps of 2 → 17 → 3 groups");
}
} }