2c50c63e
Add vk_sync module with bounded Vulkan wait helpers
a73x 2026-04-18 12:28
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -320,6 +320,27 @@ pub fn build(b: *std.Build) void { | |||
| 320 | exe_mod.addImport("cell_instance", cell_instance_mod); | 320 | exe_mod.addImport("cell_instance", cell_instance_mod); |
| 321 | main_test_mod.addImport("cell_instance", cell_instance_mod); | 321 | main_test_mod.addImport("cell_instance", cell_instance_mod); |
| 322 | 322 | ||
| 323 | // vk_sync module — bounded Vulkan synchronization helpers | ||
| 324 | const vk_sync_mod = b.createModule(.{ | ||
| 325 | .root_source_file = b.path("src/vk_sync.zig"), | ||
| 326 | .target = target, | ||
| 327 | .optimize = optimize, | ||
| 328 | }); | ||
| 329 | vk_sync_mod.addImport("vulkan", vulkan_module); | ||
| 330 | renderer_mod.addImport("vk_sync", vk_sync_mod); | ||
| 331 | renderer_test_mod.addImport("vk_sync", vk_sync_mod); | ||
| 332 | exe_mod.addImport("vk_sync", vk_sync_mod); | ||
| 333 | main_test_mod.addImport("vk_sync", vk_sync_mod); | ||
| 334 | |||
| 335 | const vk_sync_test_mod = b.createModule(.{ | ||
| 336 | .root_source_file = b.path("src/vk_sync.zig"), | ||
| 337 | .target = target, | ||
| 338 | .optimize = optimize, | ||
| 339 | }); | ||
| 340 | vk_sync_test_mod.addImport("vulkan", vulkan_module); | ||
| 341 | const vk_sync_tests = b.addTest(.{ .root_module = vk_sync_test_mod }); | ||
| 342 | test_step.dependOn(&b.addRunArtifact(vk_sync_tests).step); | ||
| 343 | |||
| 323 | // capture module — --capture mode (render a VT script to PNG) | 344 | // capture module — --capture mode (render a VT script to PNG) |
| 324 | const capture_mod = b.createModule(.{ | 345 | const capture_mod = b.createModule(.{ |
| 325 | .root_source_file = b.path("src/capture.zig"), | 346 | .root_source_file = b.path("src/capture.zig"), |
| @@ -336,6 +357,7 @@ pub fn build(b: *std.Build) void { | |||
| 336 | capture_mod.addImport("png", png_mod); | 357 | capture_mod.addImport("png", png_mod); |
| 337 | capture_mod.addImport("vulkan", vulkan_module); | 358 | capture_mod.addImport("vulkan", vulkan_module); |
| 338 | capture_mod.addImport("cell_instance", cell_instance_mod); | 359 | capture_mod.addImport("cell_instance", cell_instance_mod); |
| 360 | capture_mod.addImport("vk_sync", vk_sync_mod); | ||
| 339 | exe_mod.addImport("capture", capture_mod); | 361 | exe_mod.addImport("capture", capture_mod); |
| 340 | 362 | ||
| 341 | // imgdiff — standalone PNG comparison tool | 363 | // imgdiff — standalone PNG comparison tool |
src/vk_sync.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,161 @@ | |||
| 1 | //! Bounded Vulkan synchronization primitives. | ||
| 2 | //! | ||
| 3 | //! Replaces unbounded vkWaitForFences / vkAcquireNextImageKHR / vkDeviceWaitIdle / | ||
| 4 | //! vkQueueWaitIdle calls. The helpers here are the ONLY path callers should use | ||
| 5 | //! for blocking Vulkan operations — the grep gate in tests/check_unbounded_vk.sh | ||
| 6 | //! enforces this at CI time. | ||
| 7 | //! | ||
| 8 | //! Motivation: NVIDIA driver 595 occasionally drops a fence signal, wedging | ||
| 9 | //! vkWaitForFences(UINT64_MAX) forever. See docs/superpowers/specs/ | ||
| 10 | //! 2026-04-18-vulkan-bounded-waits-design.md for the full story. | ||
| 11 | |||
| 12 | const std = @import("std"); | ||
| 13 | const vk = @import("vulkan"); | ||
| 14 | |||
| 15 | pub const fence_wait_timeout_ns: u64 = 2_000_000_000; // 2s | ||
| 16 | pub const acquire_timeout_ns: u64 = 100_000_000; // 100ms | ||
| 17 | |||
| 18 | pub const SyncError = error{ VkWaitTimeout, VkAcquireTimeout }; | ||
| 19 | |||
| 20 | /// Bounded fence wait. Returns error.VkWaitTimeout on timeout without touching | ||
| 21 | /// the fence. Caller may safely retry on the next iteration. | ||
| 22 | pub fn waitFenceBounded( | ||
| 23 | vkd: vk.DeviceWrapper, | ||
| 24 | device: vk.Device, | ||
| 25 | fence: vk.Fence, | ||
| 26 | ) !void { | ||
| 27 | const result = try vkd.waitForFences(device, 1, @ptrCast(&fence), .true, fence_wait_timeout_ns); | ||
| 28 | if (result == .timeout) return error.VkWaitTimeout; | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Bounded image acquire. Returns the acquired image_index on success. | ||
| 32 | /// Folds VK_SUBOPTIMAL_KHR into error.OutOfDateKHR (matches existing callers, | ||
| 33 | /// which already collapse the two via swapchainNeedsRebuild). | ||
| 34 | /// Returns error.VkAcquireTimeout on VK_TIMEOUT or VK_NOT_READY. | ||
| 35 | pub fn acquireImageBounded( | ||
| 36 | vkd: vk.DeviceWrapper, | ||
| 37 | device: vk.Device, | ||
| 38 | swapchain: vk.SwapchainKHR, | ||
| 39 | semaphore: vk.Semaphore, | ||
| 40 | ) !u32 { | ||
| 41 | const acquire = vkd.acquireNextImageKHR( | ||
| 42 | device, | ||
| 43 | swapchain, | ||
| 44 | acquire_timeout_ns, | ||
| 45 | semaphore, | ||
| 46 | .null_handle, | ||
| 47 | ) catch |err| switch (err) { | ||
| 48 | error.OutOfDateKHR => return error.OutOfDateKHR, | ||
| 49 | else => return err, | ||
| 50 | }; | ||
| 51 | switch (acquire.result) { | ||
| 52 | .timeout, .not_ready => return error.VkAcquireTimeout, | ||
| 53 | .suboptimal_khr => return error.OutOfDateKHR, | ||
| 54 | .success => return acquire.image_index, | ||
| 55 | else => return acquire.image_index, // unexpected but non-error; trust the image_index | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | /// Bounded device-idle wait. For mid-flight resyncs where blocking forever | ||
| 60 | /// would be wrong. Returns error.VkWaitTimeout on timeout. | ||
| 61 | pub fn waitIdleBounded(vkd: vk.DeviceWrapper, device: vk.Device, timeout_ns: u64) !void { | ||
| 62 | // vkDeviceWaitIdle has no timeout parameter — we emulate by waiting on a | ||
| 63 | // newly-created fence submitted as a no-op, then waiting with our timeout. | ||
| 64 | // This is the minimum-cost approximation; for cases that need true idle, | ||
| 65 | // callers should use waitIdleForShutdown. | ||
| 66 | _ = timeout_ns; | ||
| 67 | _ = vkd; | ||
| 68 | _ = device; | ||
| 69 | @compileError("waitIdleBounded: not used in v1, left as a stub. Remove this compileError and implement the fence-based emulation if a caller appears."); | ||
| 70 | } | ||
| 71 | |||
| 72 | /// Unbounded device-idle wait, named to make shutdown-drain intent obvious | ||
| 73 | /// at the call site. Logs (but swallows) device-lost on shutdown since it is | ||
| 74 | /// unactionable. | ||
| 75 | pub fn waitIdleForShutdown(vkd: vk.DeviceWrapper, device: vk.Device) void { | ||
| 76 | vkd.deviceWaitIdle(device) catch |err| { | ||
| 77 | std.log.warn("waitIdleForShutdown: {s}", .{@errorName(err)}); | ||
| 78 | }; | ||
| 79 | } | ||
| 80 | |||
| 81 | /// Bounded queue-idle wait. Same shape as waitIdleBounded. | ||
| 82 | pub fn queueWaitIdleBounded(vkd: vk.DeviceWrapper, queue: vk.Queue, timeout_ns: u64) !void { | ||
| 83 | _ = queue; | ||
| 84 | _ = timeout_ns; | ||
| 85 | _ = vkd; | ||
| 86 | @compileError("queueWaitIdleBounded: not used in v1, left as a stub. Remove this compileError and implement if a caller appears."); | ||
| 87 | } | ||
| 88 | |||
| 89 | // --- logging --- | ||
| 90 | |||
| 91 | const TimeoutKind = enum { fence, acquire, atlas }; | ||
| 92 | |||
| 93 | var vk_timeout_count: std.atomic.Value(u64) = .init(0); | ||
| 94 | var last_log_ns: std.atomic.Value(i64) = .init(0); | ||
| 95 | |||
| 96 | const log_window_ns: i64 = 5 * std.time.ns_per_s; | ||
| 97 | |||
| 98 | pub fn logVkTimeout(src: std.builtin.SourceLocation, kind: TimeoutKind) void { | ||
| 99 | const n = vk_timeout_count.fetchAdd(1, .monotonic) + 1; | ||
| 100 | const now: i64 = @truncate(std.time.nanoTimestamp()); | ||
| 101 | const last = last_log_ns.load(.monotonic); | ||
| 102 | if (n == 1 or (now - last) > log_window_ns) { | ||
| 103 | last_log_ns.store(now, .monotonic); | ||
| 104 | std.log.warn( | ||
| 105 | "vk timeout #{} ({s}) at {s}:{d} — driver may be wedged", | ||
| 106 | .{ n, @tagName(kind), src.file, src.line }, | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | // --- test helpers (internal; exposed only for inline tests) --- | ||
| 112 | |||
| 113 | fn resetLogStateForTesting() void { | ||
| 114 | vk_timeout_count.store(0, .monotonic); | ||
| 115 | last_log_ns.store(0, .monotonic); | ||
| 116 | } | ||
| 117 | |||
| 118 | // --- tests --- | ||
| 119 | |||
| 120 | test "constants have expected values" { | ||
| 121 | try std.testing.expectEqual(@as(u64, 2_000_000_000), fence_wait_timeout_ns); | ||
| 122 | try std.testing.expectEqual(@as(u64, 100_000_000), acquire_timeout_ns); | ||
| 123 | } | ||
| 124 | |||
| 125 | test "logVkTimeout rate-limits to one line per window" { | ||
| 126 | // We can't easily capture std.log.warn output, but we can verify the | ||
| 127 | // counter and last_log_ns state transitions match the rate-limit logic. | ||
| 128 | resetLogStateForTesting(); | ||
| 129 | |||
| 130 | // First call always logs. | ||
| 131 | logVkTimeout(@src(), .fence); | ||
| 132 | try std.testing.expectEqual(@as(u64, 1), vk_timeout_count.load(.monotonic)); | ||
| 133 | const t1: i64 = last_log_ns.load(.monotonic); | ||
| 134 | try std.testing.expect(t1 > 0); | ||
| 135 | |||
| 136 | // Immediate second call: counter increments, last_log_ns stays (within 5s window). | ||
| 137 | logVkTimeout(@src(), .fence); | ||
| 138 | try std.testing.expectEqual(@as(u64, 2), vk_timeout_count.load(.monotonic)); | ||
| 139 | try std.testing.expectEqual(t1, last_log_ns.load(.monotonic)); | ||
| 140 | |||
| 141 | // 100 more calls in tight loop: counter grows, last_log_ns still stays. | ||
| 142 | for (0..100) |_| logVkTimeout(@src(), .fence); | ||
| 143 | try std.testing.expectEqual(@as(u64, 102), vk_timeout_count.load(.monotonic)); | ||
| 144 | try std.testing.expectEqual(t1, last_log_ns.load(.monotonic)); | ||
| 145 | } | ||
| 146 | |||
| 147 | test "logVkTimeout re-fires after simulated window elapsed" { | ||
| 148 | resetLogStateForTesting(); | ||
| 149 | |||
| 150 | logVkTimeout(@src(), .acquire); | ||
| 151 | const t1: i64 = last_log_ns.load(.monotonic); | ||
| 152 | |||
| 153 | // Simulate window expiry by rewinding last_log_ns past the 5s threshold. | ||
| 154 | last_log_ns.store(t1 - 6 * std.time.ns_per_s, .monotonic); | ||
| 155 | |||
| 156 | logVkTimeout(@src(), .acquire); | ||
| 157 | const t2: i64 = last_log_ns.load(.monotonic); | ||
| 158 | |||
| 159 | try std.testing.expect(t2 > t1 - 6 * std.time.ns_per_s); | ||
| 160 | try std.testing.expectEqual(@as(u64, 2), vk_timeout_count.load(.monotonic)); | ||
| 161 | } | ||