detached 2d62471

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5454 hdrNits float32
5555 }
5656
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.
5860 type deferredFree struct {
5961 frame uint64
62+ sem vk.Semaphore
6063 free func()
6164 }
6265
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+
6375 func (r *Renderer) createSlots() error {
6476 d := r.dd
6577 cmds := make([]vk.CommandBuffer, frameSlots)
@@ -154,10 +166,19 @@ func (r *Renderer) retire(frame uint64, free func()) {
154166 r.deferred = append(r.deferred, deferredFree{frame: frame, free: free})
155167 }
156168
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+
157178 func (r *Renderer) runDeferred() {
158179 r.deferred = slices.DeleteFunc(r.deferred, func(f deferredFree) bool {
159180 if f.frame <= r.completed {
160- f.free()
181+ r.free(f)
161182 return true
162183 }
163184 return false
@@ -311,8 +332,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
311332 r.frameWaits = waits
312333 frame := r.frame
313334 for _, sem := range waits {
314- sem := sem
315- r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) })
335+ r.retireSemaphore(frame, sem)
316336 }
317337 sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd}
318338 submit := &sc.submit
@@ -54,12 +54,24 @@ type callScratch struct {
54 hdrNits float3254 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 uint6461 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.dd76 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 true182 return true
162 }183 }
163 return false184 return false
@@ -311,8 +332,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
311 r.frameWaits = waits332 r.frameWaits = waits
312 frame := r.frame333 frame := r.frame
313 for _, sem := range waits {334 for _, sem := range waits {
314- sem := sem335+ 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.submit338 submit := &sc.submit
M internal/adapters/vulkan/scene_perf_test.go +28 -4
@@ -140,8 +140,8 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
140140 // A steady frame that draws a client dmabuf also records the ownership
141141 // barriers (acquire and release) of its image, which allocate nothing. The
142142 // 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.
145145 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
146146 r, err := New(80, 32)
147147 if err != nil {
@@ -182,8 +182,32 @@ func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
182182 moved()
183183 allocs := testing.AllocsPerRun(20, moved)
184184 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))
187211 }
188212 }
189213
@@ -140,8 +140,8 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
140 // A steady frame that draws a client dmabuf also records the ownership140 // A steady frame that draws a client dmabuf also records the ownership
141 // barriers (acquire and release) of its image, which allocate nothing. The141 // barriers (acquire and release) of its image, which allocate nothing. The
142 // frame still allocates more than the SHM scene: importing the client's142 // 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 a143+// 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close