perf(vulkan): retire wait semaphores without a closure
Each dmabuf wait semaphore was retired through a heap-allocated closure. deferredFree now carries the semaphore handle and runDeferred destroys it directly. Steady-state dmabuf frame: 6 down to 5 allocs.
- commit
- 2d62471bece8f8776d262116f889c8ba5d00033b
- author
- brice · · 2026-10-16 14:04 UTC
- parent
- 57b1805
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +52 -8
M internal/adapters/vulkan/frame.go +24 -4
| @@ -54,12 +54,24 @@ type callScratch struct { | |||
| 54 | 54 | hdrNits float32 | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | - | // deferredFree runs once frame completed. | |
| 57 | + | // deferredFree runs once frame completed. The hot path, a wait semaphore | |
| 58 | + | // of the frame, is typed (sem, with free nil) so it costs no closure; cold | |
| 59 | + | // paths set free. | |
| 58 | 60 | type deferredFree struct { | |
| 59 | 61 | frame uint64 | |
| 62 | + | sem vk.Semaphore | |
| 60 | 63 | free func() | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 66 | + | // free frees what f holds. | |
| 67 | + | func (r *Renderer) free(f deferredFree) { | |
| 68 | + | if f.free != nil { | |
| 69 | + | f.free() | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | r.dd.DestroySemaphore(r.device, f.sem, nil) | |
| 73 | + | } | |
| 74 | + | ||
| 63 | 75 | func (r *Renderer) createSlots() error { | |
| 64 | 76 | d := r.dd | |
| 65 | 77 | cmds := make([]vk.CommandBuffer, frameSlots) | |
| @@ -154,10 +166,19 @@ func (r *Renderer) retire(frame uint64, free func()) { | |||
| 154 | 166 | r.deferred = append(r.deferred, deferredFree{frame: frame, free: free}) | |
| 155 | 167 | } | |
| 156 | 168 | ||
| 169 | + | // retireSemaphore destroys sem once the frame that last used it completed. | |
| 170 | + | func (r *Renderer) retireSemaphore(frame uint64, sem vk.Semaphore) { | |
| 171 | + | if frame <= r.completed { | |
| 172 | + | r.dd.DestroySemaphore(r.device, sem, nil) | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | r.deferred = append(r.deferred, deferredFree{frame: frame, sem: sem}) | |
| 176 | + | } | |
| 177 | + | ||
| 157 | 178 | func (r *Renderer) runDeferred() { | |
| 158 | 179 | r.deferred = slices.DeleteFunc(r.deferred, func(f deferredFree) bool { | |
| 159 | 180 | if f.frame <= r.completed { | |
| 160 | - | f.free() | |
| 181 | + | r.free(f) | |
| 161 | 182 | return true | |
| 162 | 183 | } | |
| 163 | 184 | return false | |
| @@ -311,8 +332,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 311 | 332 | r.frameWaits = waits | |
| 312 | 333 | frame := r.frame | |
| 313 | 334 | for _, sem := range waits { | |
| 314 | - | sem := sem | |
| 315 | - | r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) }) | |
| 335 | + | r.retireSemaphore(frame, sem) | |
| 316 | 336 | } | |
| 317 | 337 | sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd} | |
| 318 | 338 | submit := &sc.submit | |
| @@ -54,12 +54,24 @@ type callScratch struct { | |||||
| 54 | hdrNits float32 | 54 | hdrNits float32 | ||
| 55 | } | 55 | } | ||
| 56 | 56 | ||||
| 57 | - | // deferredFree runs once frame completed. | 57 | + | // deferredFree runs once frame completed. The hot path, a wait semaphore |
| 58 | + | // of the frame, is typed (sem, with free nil) so it costs no closure; cold | |||
| 59 | + | // paths set free. | |||
| 58 | type deferredFree struct { | 60 | type deferredFree struct { | ||
| 59 | frame uint64 | 61 | frame uint64 | ||
| 62 | + | sem vk.Semaphore | |||
| 60 | free func() | 63 | free func() | ||
| 61 | } | 64 | } | ||
| 62 | 65 | ||||
| 66 | + | // free frees what f holds. | |||
| 67 | + | func (r *Renderer) free(f deferredFree) { | |||
| 68 | + | if f.free != nil { | |||
| 69 | + | f.free() | |||
| 70 | + | return | |||
| 71 | + | } | |||
| 72 | + | r.dd.DestroySemaphore(r.device, f.sem, nil) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 63 | func (r *Renderer) createSlots() error { | 75 | func (r *Renderer) createSlots() error { | ||
| 64 | d := r.dd | 76 | d := r.dd | ||
| 65 | cmds := make([]vk.CommandBuffer, frameSlots) | 77 | cmds := make([]vk.CommandBuffer, frameSlots) | ||
| @@ -154,10 +166,19 @@ func (r *Renderer) retire(frame uint64, free func()) { | |||||
| 154 | r.deferred = append(r.deferred, deferredFree{frame: frame, free: free}) | 166 | r.deferred = append(r.deferred, deferredFree{frame: frame, free: free}) | ||
| 155 | } | 167 | } | ||
| 156 | 168 | ||||
| 169 | + | // retireSemaphore destroys sem once the frame that last used it completed. | |||
| 170 | + | func (r *Renderer) retireSemaphore(frame uint64, sem vk.Semaphore) { | |||
| 171 | + | if frame <= r.completed { | |||
| 172 | + | r.dd.DestroySemaphore(r.device, sem, nil) | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | r.deferred = append(r.deferred, deferredFree{frame: frame, sem: sem}) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 157 | func (r *Renderer) runDeferred() { | 178 | func (r *Renderer) runDeferred() { | ||
| 158 | r.deferred = slices.DeleteFunc(r.deferred, func(f deferredFree) bool { | 179 | r.deferred = slices.DeleteFunc(r.deferred, func(f deferredFree) bool { | ||
| 159 | if f.frame <= r.completed { | 180 | if f.frame <= r.completed { | ||
| 160 | - | f.free() | 181 | + | r.free(f) |
| 161 | return true | 182 | return true | ||
| 162 | } | 183 | } | ||
| 163 | return false | 184 | return false | ||
| @@ -311,8 +332,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 311 | r.frameWaits = waits | 332 | r.frameWaits = waits | ||
| 312 | frame := r.frame | 333 | frame := r.frame | ||
| 313 | for _, sem := range waits { | 334 | for _, sem := range waits { | ||
| 314 | - | sem := sem | 335 | + | r.retireSemaphore(frame, sem) |
| 315 | - | r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) }) | |||
| 316 | } | 336 | } | ||
| 317 | sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd} | 337 | sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd} | ||
| 318 | submit := &sc.submit | 338 | submit := &sc.submit | ||
M internal/adapters/vulkan/scene_perf_test.go +28 -4
| @@ -140,8 +140,8 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||
| 140 | 140 | // A steady frame that draws a client dmabuf also records the ownership | |
| 141 | 141 | // barriers (acquire and release) of its image, which allocate nothing. The | |
| 142 | 142 | // frame still allocates more than the SHM scene: importing the client's | |
| 143 | - | // read fence and its retire closure (about 4 per frame). Skipped without a | |
| 144 | - | // GPU or /dev/udmabuf. | |
| 143 | + | // read fence (about 5 per frame, measured 5.0; the semaphore retire is | |
| 144 | + | // typed and costs no closure). Skipped without a GPU or /dev/udmabuf. | |
| 145 | 145 | func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | |
| 146 | 146 | r, err := New(80, 32) | |
| 147 | 147 | if err != nil { | |
| @@ -182,8 +182,32 @@ func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | |||
| 182 | 182 | moved() | |
| 183 | 183 | allocs := testing.AllocsPerRun(20, moved) | |
| 184 | 184 | t.Logf("dmabuf Render: %.1f allocs/frame", allocs) | |
| 185 | - | if allocs > 7 { | |
| 186 | - | t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=7 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD) | |
| 185 | + | if allocs > 5 { | |
| 186 | + | t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=5 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Retiring a frame's wait semaphore appends a typed entry to the reused | |
| 191 | + | // deferred list: no closure, no allocation once the list has its capacity. | |
| 192 | + | func TestRetireSemaphoreAllocatesNothing(t *testing.T) { | |
| 193 | + | r, err := New(32, 32) | |
| 194 | + | if err != nil { | |
| 195 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 196 | + | } | |
| 197 | + | defer r.Close() | |
| 198 | + | r.completed = 0 | |
| 199 | + | retire := func() { | |
| 200 | + | // A null handle is a valid vkDestroySemaphore argument. | |
| 201 | + | r.retireSemaphore(r.completed+1, 0) | |
| 202 | + | r.completed++ | |
| 203 | + | r.runDeferred() | |
| 204 | + | } | |
| 205 | + | retire() | |
| 206 | + | if allocs := testing.AllocsPerRun(50, retire); allocs != 0 { | |
| 207 | + | t.Errorf("retireSemaphore: %.1f allocs, want 0", allocs) | |
| 208 | + | } | |
| 209 | + | if len(r.deferred) != 0 { | |
| 210 | + | t.Errorf("%d deferred entries left after their frame completed", len(r.deferred)) | |
| 187 | 211 | } | |
| 188 | 212 | } | |
| 189 | 213 | ||
| @@ -140,8 +140,8 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||||
| 140 | // A steady frame that draws a client dmabuf also records the ownership | 140 | // A steady frame that draws a client dmabuf also records the ownership | ||
| 141 | // barriers (acquire and release) of its image, which allocate nothing. The | 141 | // barriers (acquire and release) of its image, which allocate nothing. The | ||
| 142 | // frame still allocates more than the SHM scene: importing the client's | 142 | // frame still allocates more than the SHM scene: importing the client's | ||
| 143 | - | // read fence and its retire closure (about 4 per frame). Skipped without a | 143 | + | // read fence (about 5 per frame, measured 5.0; the semaphore retire is |
| 144 | - | // GPU or /dev/udmabuf. | 144 | + | // typed and costs no closure). Skipped without a GPU or /dev/udmabuf. |
| 145 | func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | 145 | func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | ||
| 146 | r, err := New(80, 32) | 146 | r, err := New(80, 32) | ||
| 147 | if err != nil { | 147 | if err != nil { | ||
| @@ -182,8 +182,32 @@ func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | |||||
| 182 | moved() | 182 | moved() | ||
| 183 | allocs := testing.AllocsPerRun(20, moved) | 183 | allocs := testing.AllocsPerRun(20, moved) | ||
| 184 | t.Logf("dmabuf Render: %.1f allocs/frame", allocs) | 184 | t.Logf("dmabuf Render: %.1f allocs/frame", allocs) | ||
| 185 | - | if allocs > 7 { | 185 | + | if allocs > 5 { |
| 186 | - | t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=7 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD) | 186 | + | t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=5 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD) |
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // Retiring a frame's wait semaphore appends a typed entry to the reused | |||
| 191 | + | // deferred list: no closure, no allocation once the list has its capacity. | |||
| 192 | + | func TestRetireSemaphoreAllocatesNothing(t *testing.T) { | |||
| 193 | + | r, err := New(32, 32) | |||
| 194 | + | if err != nil { | |||
| 195 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 196 | + | } | |||
| 197 | + | defer r.Close() | |||
| 198 | + | r.completed = 0 | |||
| 199 | + | retire := func() { | |||
| 200 | + | // A null handle is a valid vkDestroySemaphore argument. | |||
| 201 | + | r.retireSemaphore(r.completed+1, 0) | |||
| 202 | + | r.completed++ | |||
| 203 | + | r.runDeferred() | |||
| 204 | + | } | |||
| 205 | + | retire() | |||
| 206 | + | if allocs := testing.AllocsPerRun(50, retire); allocs != 0 { | |||
| 207 | + | t.Errorf("retireSemaphore: %.1f allocs, want 0", allocs) | |||
| 208 | + | } | |||
| 209 | + | if len(r.deferred) != 0 { | |||
| 210 | + | t.Errorf("%d deferred entries left after their frame completed", len(r.deferred)) | |||
| 187 | } | 211 | } | ||
| 188 | } | 212 | } | ||
| 189 | 213 | ||||