detached 57b1805

perf(wayland): cache shm buffer content and keep frame callback queues

buffer.content built a new SHMBuffer and an os.FileInfo on every commit: the SHMBuffer is now built once per buffer and the size is still checked every commit with unix.Fstat on the stack. dueFrames reuses a fire scratch, and sendFrames keeps the emptied queue's backing array instead of deleting it. Queued commit: 18 down to 16 allocs/op.
commit
57b180563c6a57455a9a199561d8cb07166bec8a
author
brice · · 2026-10-16 14:04 UTC
parent
fea658c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +118 -7
M internal/adapters/wayland/alloc_hotpath_test.go +86 -0
@@ -1,11 +1,15 @@
11 package wayland
22
33 import (
4+ "os"
5+ "slices"
46 "testing"
57 "time"
68
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
710 "github.com/bnema/neferwl/internal/ports"
811 "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
913 )
1014
1115 // The steady-state hot paths that run for every frame or commit allocate
@@ -125,3 +129,85 @@ func TestEmitContentAllocations(t *testing.T) {
125129 }
126130 t.Logf("emitContent: %.2f allocs", allocs)
127131 }
132+
133+// Firing an output's callbacks keeps its queue's backing array, so the next
134+// commit's queueFrames appends in place (before: the queue was deleted by
135+// every fire and each commit grew a fresh slice). An emptied queue reads
136+// like an absent one, and pins no fired callback.
137+func TestFrameQueueKeepsBackingArray(t *testing.T) {
138+ s, events, commands, _, _, dir := presentServerOn(t, testOutputs)
139+ c := protocolClient(t, s, dir)
140+ w, surf, _ := surfaceMapper(t, c, events)()
141+ placeWindow(t, s, commands, w, "HEADLESS-1")
142+ awaitFrame(t, c, requestFrame(t, c, surf))
143+ awaitFrame(t, c, requestFrame(t, c, surf))
144+ var first *wayland.Callback
145+ var head **wayland.Callback
146+ s.display.Do(func() {
147+ // The pacer fired the queue during the awaitFrame calls above.
148+ queue := s.awaiting["HEADLESS-1"]
149+ if len(queue) != 0 || cap(queue) == 0 {
150+ t.Errorf("fired queue: len %d cap %d", len(queue), cap(queue))
151+ return
152+ }
153+ first, head = queue[:1][0], &queue[:1][0]
154+ if len(s.frameOwners) != 0 {
155+ t.Errorf("fired queue keeps %d frame owners", len(s.frameOwners))
156+ }
157+ })
158+ if first != nil {
159+ t.Fatal("fired queue pins a callback")
160+ }
161+ awaitFrame(t, c, requestFrame(t, c, surf))
162+ s.display.Do(func() {
163+ if again := s.awaiting["HEADLESS-1"]; cap(again) == 0 || &again[:1][0] != head {
164+ t.Error("queue reallocated between frames")
165+ }
166+ })
167+}
168+
169+// dueFrames hands out one scratch slice for every tick.
170+func TestDueFramesFireScratch(t *testing.T) {
171+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
172+ s := &Server{outputs: []*output{out}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{"DP-2": {nil}}}
173+ now := time.Unix(100, 0)
174+ flipped := map[string]bool{"DP-2": true}
175+ fire, _, _ := s.dueFrames(now, flipped)
176+ if !slices.Equal(fire, []string{"DP-2"}) {
177+ t.Fatalf("fire %v", fire)
178+ }
179+ if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, flipped) }); allocs != 0 {
180+ t.Errorf("firing dueFrames: %.1f allocs, want 0", allocs)
181+ }
182+}
183+
184+// A buffer sends the same SHMBuffer on every commit and still checks the
185+// file's size each time: a client may truncate it.
186+func TestBufferContentAllocations(t *testing.T) {
187+ fd, err := unix.MemfdCreate("content-allocs", unix.MFD_CLOEXEC)
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ f := os.NewFile(uintptr(fd), "content-allocs")
192+ defer f.Close()
193+ if err := f.Truncate(28); err != nil {
194+ t.Fatal(err)
195+ }
196+ b := &buffer{pool: &pool{id: 3, file: f, size: 28}, offset: 4, width: 2, height: 2, stride: 16}
197+ first, ok := b.content(1)
198+ if !ok {
199+ t.Fatal("no content")
200+ }
201+ if allocs := testing.AllocsPerRun(100, func() { b.content(1) }); allocs != 0 {
202+ t.Errorf("buffer.content: %.1f allocs, want 0", allocs)
203+ }
204+ if again, _ := b.content(2); again.SHM != first.SHM {
205+ t.Fatal("SHMBuffer rebuilt per commit")
206+ }
207+ if err := f.Truncate(27); err != nil {
208+ t.Fatal(err)
209+ }
210+ if _, ok := b.content(1); ok {
211+ t.Fatal("content from a truncated pool")
212+ }
213+}
@@ -1,11 +1,15 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "os"
5+ "slices"
4 "testing"6 "testing"
5 "time"7 "time"
6 8
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
7 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
8 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
9 )13 )
10 14
11 // The steady-state hot paths that run for every frame or commit allocate15 // The steady-state hot paths that run for every frame or commit allocate
@@ -125,3 +129,85 @@ func TestEmitContentAllocations(t *testing.T) {
125 }129 }
126 t.Logf("emitContent: %.2f allocs", allocs)130 t.Logf("emitContent: %.2f allocs", allocs)
127 }131 }
132+
133+// Firing an output's callbacks keeps its queue's backing array, so the next
134+// commit's queueFrames appends in place (before: the queue was deleted by
135+// every fire and each commit grew a fresh slice). An emptied queue reads
136+// like an absent one, and pins no fired callback.
137+func TestFrameQueueKeepsBackingArray(t *testing.T) {
138+ s, events, commands, _, _, dir := presentServerOn(t, testOutputs)
139+ c := protocolClient(t, s, dir)
140+ w, surf, _ := surfaceMapper(t, c, events)()
141+ placeWindow(t, s, commands, w, "HEADLESS-1")
142+ awaitFrame(t, c, requestFrame(t, c, surf))
143+ awaitFrame(t, c, requestFrame(t, c, surf))
144+ var first *wayland.Callback
145+ var head **wayland.Callback
146+ s.display.Do(func() {
147+ // The pacer fired the queue during the awaitFrame calls above.
148+ queue := s.awaiting["HEADLESS-1"]
149+ if len(queue) != 0 || cap(queue) == 0 {
150+ t.Errorf("fired queue: len %d cap %d", len(queue), cap(queue))
151+ return
152+ }
153+ first, head = queue[:1][0], &queue[:1][0]
154+ if len(s.frameOwners) != 0 {
155+ t.Errorf("fired queue keeps %d frame owners", len(s.frameOwners))
156+ }
157+ })
158+ if first != nil {
159+ t.Fatal("fired queue pins a callback")
160+ }
161+ awaitFrame(t, c, requestFrame(t, c, surf))
162+ s.display.Do(func() {
163+ if again := s.awaiting["HEADLESS-1"]; cap(again) == 0 || &again[:1][0] != head {
164+ t.Error("queue reallocated between frames")
165+ }
166+ })
167+}
168+
169+// dueFrames hands out one scratch slice for every tick.
170+func TestDueFramesFireScratch(t *testing.T) {
171+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
172+ s := &Server{outputs: []*output{out}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{"DP-2": {nil}}}
173+ now := time.Unix(100, 0)
174+ flipped := map[string]bool{"DP-2": true}
175+ fire, _, _ := s.dueFrames(now, flipped)
176+ if !slices.Equal(fire, []string{"DP-2"}) {
177+ t.Fatalf("fire %v", fire)
178+ }
179+ if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, flipped) }); allocs != 0 {
180+ t.Errorf("firing dueFrames: %.1f allocs, want 0", allocs)
181+ }
182+}
183+
184+// A buffer sends the same SHMBuffer on every commit and still checks the
185+// file's size each time: a client may truncate it.
186+func TestBufferContentAllocations(t *testing.T) {
187+ fd, err := unix.MemfdCreate("content-allocs", unix.MFD_CLOEXEC)
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ f := os.NewFile(uintptr(fd), "content-allocs")
192+ defer f.Close()
193+ if err := f.Truncate(28); err != nil {
194+ t.Fatal(err)
195+ }
196+ b := &buffer{pool: &pool{id: 3, file: f, size: 28}, offset: 4, width: 2, height: 2, stride: 16}
197+ first, ok := b.content(1)
198+ if !ok {
199+ t.Fatal("no content")
200+ }
201+ if allocs := testing.AllocsPerRun(100, func() { b.content(1) }); allocs != 0 {
202+ t.Errorf("buffer.content: %.1f allocs, want 0", allocs)
203+ }
204+ if again, _ := b.content(2); again.SHM != first.SHM {
205+ t.Fatal("SHMBuffer rebuilt per commit")
206+ }
207+ if err := f.Truncate(27); err != nil {
208+ t.Fatal(err)
209+ }
210+ if _, ok := b.content(1); ok {
211+ t.Fatal("content from a truncated pool")
212+ }
213+}
M internal/adapters/wayland/frames.go +14 -3
@@ -253,6 +253,7 @@ func (s *Server) relocateCallbacks(root *surface) {
253253 dest := s.frameOutput(owner)
254254 moved[dest] = append(moved[dest], cb)
255255 }
256+ clear(callbacks[len(kept):]) // the filtered tail must not pin callbacks
256257 if len(kept) == 0 {
257258 delete(s.awaiting, name)
258259 } else {
@@ -270,8 +271,10 @@ func (s *Server) relocateCallbacks(root *surface) {
270271
271272 // dueFrames returns the queues to fire now: flipped outputs and those past
272273 // their deadline. wait is the time to the next deadline; idle is set when
273-// no callback waits after firing.
274+// no callback waits after firing. fire is the tick's scratch, valid until
275+// the next call (display goroutine only).
274276 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
277+ fire = s.frameFire[:0]
275278 if s.framePeriods == nil {
276279 s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1)
277280 }
@@ -284,7 +287,9 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
284287 for name, callbacks := range s.awaiting {
285288 if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue {
286289 delete(s.awaiting, name)
287- s.awaiting[""] = append(s.awaiting[""], callbacks...)
290+ if len(callbacks) > 0 {
291+ s.awaiting[""] = append(s.awaiting[""], callbacks...)
292+ }
288293 }
289294 }
290295 periods[""] = defaultFramePeriod
@@ -330,6 +335,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
330335 wait = 0
331336 }
332337 }
338+ s.frameFire = fire
333339 return fire, max(wait, time.Millisecond), !waiting
334340 }
335341
@@ -341,10 +347,15 @@ func paces(p ports.OutputPresented) bool { return p.Flip != nil && p.Flip.Hardwa
341347 // sendFrames fires an output's callbacks; flipped: a flip of it fired them.
342348 func (s *Server) sendFrames(name string, flipped bool) {
343349 callbacks := s.awaiting[name]
344- delete(s.awaiting, name)
345350 if len(callbacks) == 0 {
346351 return
347352 }
353+ // The queue keeps its backing array for the next frame (an empty queue
354+ // reads like an absent one). Nothing below queues a callback.
355+ defer func() {
356+ clear(callbacks)
357+ s.awaiting[name] = callbacks[:0]
358+ }()
348359 // done carries the flip's time when the output flips (the frame the
349360 // client drew for is on screen), else now.
350361 ms := uint32(time.Since(s.started).Milliseconds())
@@ -253,6 +253,7 @@ func (s *Server) relocateCallbacks(root *surface) {
253 dest := s.frameOutput(owner)253 dest := s.frameOutput(owner)
254 moved[dest] = append(moved[dest], cb)254 moved[dest] = append(moved[dest], cb)
255 }255 }
256+ clear(callbacks[len(kept):]) // the filtered tail must not pin callbacks
256 if len(kept) == 0 {257 if len(kept) == 0 {
257 delete(s.awaiting, name)258 delete(s.awaiting, name)
258 } else {259 } else {
@@ -270,8 +271,10 @@ func (s *Server) relocateCallbacks(root *surface) {
270 271
271 // dueFrames returns the queues to fire now: flipped outputs and those past272 // dueFrames returns the queues to fire now: flipped outputs and those past
272 // their deadline. wait is the time to the next deadline; idle is set when273 // their deadline. wait is the time to the next deadline; idle is set when
273-// no callback waits after firing.274+// no callback waits after firing. fire is the tick's scratch, valid until
275+// the next call (display goroutine only).
274 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {276 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
277+ fire = s.frameFire[:0]
275 if s.framePeriods == nil {278 if s.framePeriods == nil {
276 s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1)279 s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1)
277 }280 }
@@ -284,7 +287,9 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
284 for name, callbacks := range s.awaiting {287 for name, callbacks := range s.awaiting {
285 if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue {288 if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue {
286 delete(s.awaiting, name)289 delete(s.awaiting, name)
287- s.awaiting[""] = append(s.awaiting[""], callbacks...)290+ if len(callbacks) > 0 {
291+ s.awaiting[""] = append(s.awaiting[""], callbacks...)
292+ }
288 }293 }
289 }294 }
290 periods[""] = defaultFramePeriod295 periods[""] = defaultFramePeriod
@@ -330,6 +335,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
330 wait = 0335 wait = 0
331 }336 }
332 }337 }
338+ s.frameFire = fire
333 return fire, max(wait, time.Millisecond), !waiting339 return fire, max(wait, time.Millisecond), !waiting
334 }340 }
335 341
@@ -341,10 +347,15 @@ func paces(p ports.OutputPresented) bool { return p.Flip != nil && p.Flip.Hardwa
341 // sendFrames fires an output's callbacks; flipped: a flip of it fired them.347 // sendFrames fires an output's callbacks; flipped: a flip of it fired them.
342 func (s *Server) sendFrames(name string, flipped bool) {348 func (s *Server) sendFrames(name string, flipped bool) {
343 callbacks := s.awaiting[name]349 callbacks := s.awaiting[name]
344- delete(s.awaiting, name)
345 if len(callbacks) == 0 {350 if len(callbacks) == 0 {
346 return351 return
347 }352 }
353+ // The queue keeps its backing array for the next frame (an empty queue
354+ // reads like an absent one). Nothing below queues a callback.
355+ defer func() {
356+ clear(callbacks)
357+ s.awaiting[name] = callbacks[:0]
358+ }()
348 // done carries the flip's time when the output flips (the frame the359 // done carries the flip's time when the output flips (the frame the
349 // client drew for is on screen), else now.360 // client drew for is on screen), else now.
350 ms := uint32(time.Since(s.started).Milliseconds())361 ms := uint32(time.Since(s.started).Milliseconds())
M internal/adapters/wayland/globals.go +16 -4
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/go-wayland-bindings/server/wayland"
88 "github.com/bnema/neferwl/internal/ports"
99 "github.com/bnema/purego-libwayland/server"
10+ "golang.org/x/sys/unix"
1011 )
1112
1213 func registerGlobals(d *server.Display, o Options, s *Server) error {
@@ -389,6 +390,12 @@ type buffer struct {
389390 pool *pool
390391 offset, width, height, stride int
391392 format uint32
393+ // shm and fd are built on the first content call. The pool, offset and
394+ // stride never change, so every commit sends the same read-only
395+ // SHMBuffer. The pool keeps the file open while the buffer lives, which
396+ // keeps fd valid.
397+ shm *ports.SHMBuffer
398+ fd int
392399 }
393400
394401 func (*buffer) Destroy(*wayland.Buffer) {}
@@ -417,13 +424,18 @@ func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) {
417424 }
418425
419426 // content points the renderer at the pool; a file shrunk below the buffer
420-// is refused.
427+// is refused. The size is checked on every call: a client may truncate the
428+// file at any time, so it is never cached.
421429 func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
422- if !fileHolds(b.pool.file, int64(b.offset+(b.height-1)*b.stride+b.width*4)) {
430+ if b.shm == nil {
431+ b.shm = &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride}
432+ b.fd = int(b.pool.file.Fd())
433+ }
434+ var st unix.Stat_t
435+ if unix.Fstat(b.fd, &st) != nil || st.Size < int64(b.offset+(b.height-1)*b.stride+b.width*4) {
423436 return ports.SurfaceContent{}, false
424437 }
425- shm := &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride}
426- return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: shm}, true
438+ return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: b.shm}, true
427439 }
428440
429441 type seat struct{ server *Server }
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/go-wayland-bindings/server/wayland"7 "github.com/bnema/go-wayland-bindings/server/wayland"
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9 "github.com/bnema/purego-libwayland/server"9 "github.com/bnema/purego-libwayland/server"
10+ "golang.org/x/sys/unix"
10 )11 )
11 12
12 func registerGlobals(d *server.Display, o Options, s *Server) error {13 func registerGlobals(d *server.Display, o Options, s *Server) error {
@@ -389,6 +390,12 @@ type buffer struct {
389 pool *pool390 pool *pool
390 offset, width, height, stride int391 offset, width, height, stride int
391 format uint32392 format uint32
393+ // shm and fd are built on the first content call. The pool, offset and
394+ // stride never change, so every commit sends the same read-only
395+ // SHMBuffer. The pool keeps the file open while the buffer lives, which
396+ // keeps fd valid.
397+ shm *ports.SHMBuffer
398+ fd int
392 }399 }
393 400
394 func (*buffer) Destroy(*wayland.Buffer) {}401 func (*buffer) Destroy(*wayland.Buffer) {}
@@ -417,13 +424,18 @@ func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) {
417 }424 }
418 425
419 // content points the renderer at the pool; a file shrunk below the buffer426 // content points the renderer at the pool; a file shrunk below the buffer
420-// is refused.427+// is refused. The size is checked on every call: a client may truncate the
428+// file at any time, so it is never cached.
421 func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {429 func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
422- if !fileHolds(b.pool.file, int64(b.offset+(b.height-1)*b.stride+b.width*4)) {430+ if b.shm == nil {
431+ b.shm = &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride}
432+ b.fd = int(b.pool.file.Fd())
433+ }
434+ var st unix.Stat_t
435+ if unix.Fstat(b.fd, &st) != nil || st.Size < int64(b.offset+(b.height-1)*b.stride+b.width*4) {
423 return ports.SurfaceContent{}, false436 return ports.SurfaceContent{}, false
424 }437 }
425- shm := &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride}438+ return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: b.shm}, true
426- return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: shm}, true
427 }439 }
428 440
429 type seat struct{ server *Server }441 type seat struct{ server *Server }
M internal/adapters/wayland/server.go +2 -0
@@ -189,6 +189,8 @@ type Server struct {
189189 sink *presentSink
190190 // framePeriods is the tick's scratch (display goroutine only).
191191 framePeriods map[string]time.Duration
192+ // frameFire is dueFrames' result slice, reused across ticks.
193+ frameFire []string
192194 // held buffers wait until no output reads them (release.go); reports
193195 // are the outputs' latest ports.OutputPresented.
194196 held []heldBuffer
@@ -189,6 +189,8 @@ type Server struct {
189 sink *presentSink189 sink *presentSink
190 // framePeriods is the tick's scratch (display goroutine only).190 // framePeriods is the tick's scratch (display goroutine only).
191 framePeriods map[string]time.Duration191 framePeriods map[string]time.Duration
192+ // frameFire is dueFrames' result slice, reused across ticks.
193+ frameFire []string
192 // held buffers wait until no output reads them (release.go); reports194 // held buffers wait until no output reads them (release.go); reports
193 // are the outputs' latest ports.OutputPresented.195 // are the outputs' latest ports.OutputPresented.
194 held []heldBuffer196 held []heldBuffer

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