perf(wayland): drop per-commit and per-tick allocations
- releaseHeld reuses a Server scratch slice for the live output reports instead of making one every pacer tick. - Damage and DamageBuffer collect into slices that travel with the pooled update: queueUpdate hands the retired update's capacity to the surface's next commit, so rects stop growing a fresh array per commit. The queued update keeps its own rects until it retires. - An explicit-sync commit keeps its points in a per-surface scratch that queueUpdate moves into storage inside the update, instead of a heap commitSync per commit. - emitContent appends the damage history inside one backing array, always past the end of every published slice, and publishes a capacity-clipped slice, so readers still see an immutable history and cannot append into the spare. A new array is allocated once per ~29 publications instead of every one. The content-change channel is created lazily by the only waiter (forwardContents) and closed/cleared on publish, so a commit allocates a channel only when someone waits. Tests assert zero allocations for releaseHeld, damage+queueUpdate+apply and emitContent, and that retained histories and queued damage never change.
- commit
- cea7d5e477dbc7c6e48719f3012a749c2dfdd4e6
- author
- brice · · 2026-10-10 14:29 UTC
- committer
- brice · · 2026-10-10 15:39 UTC
- parent
- af1828e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +202 -18
A internal/adapters/wayland/alloc_hotpath_test.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // The steady-state hot paths that run for every frame or commit allocate | |
| 12 | + | // nothing (or one pooled object per damage-history generation). | |
| 13 | + | ||
| 14 | + | func TestReleaseHeldAllocations(t *testing.T) { | |
| 15 | + | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |
| 16 | + | s := &Server{outputs: []*output{out}, reports: map[string]ports.OutputPresented{}} | |
| 17 | + | reports := []ports.OutputPresented{{Output: "DP-2", Seen: map[ports.WindowID]uint64{1: 1}}} | |
| 18 | + | now := time.Unix(100, 0) | |
| 19 | + | s.releaseHeld(now, reports) | |
| 20 | + | if allocs := testing.AllocsPerRun(100, func() { s.releaseHeld(now, reports) }); allocs != 0 { | |
| 21 | + | t.Errorf("releaseHeld: %.1f allocs, want 0", allocs) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestCommitDamageAllocations(t *testing.T) { | |
| 26 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |
| 27 | + | surf := &surface{server: srv} | |
| 28 | + | commit := func() { | |
| 29 | + | surf.Damage(nil, 0, 0, 4, 4) | |
| 30 | + | surf.DamageBuffer(nil, 1, 1, 2, 2) | |
| 31 | + | surf.queueUpdate() | |
| 32 | + | surf.queue[0].applyGraph() | |
| 33 | + | } | |
| 34 | + | commit() | |
| 35 | + | if allocs := testing.AllocsPerRun(100, commit); allocs != 0 { | |
| 36 | + | t.Errorf("damage requests + queueUpdate + apply: %.1f allocs, want 0", allocs) | |
| 37 | + | } | |
| 38 | + | // The queued update keeps its rects while the next commit collects its own. | |
| 39 | + | surf.Damage(nil, 0, 0, 1, 1) | |
| 40 | + | surf.queueUpdate() | |
| 41 | + | first := surf.queue[0] | |
| 42 | + | surf.Damage(nil, 5, 5, 2, 2) | |
| 43 | + | if len(first.damage) != 1 || first.damage[0].W != 1 || len(surf.next.damage) != 1 || surf.next.damage[0].W != 2 { | |
| 44 | + | t.Fatalf("damage shared between commits: queued %v pending %v", first.damage, surf.next.damage) | |
| 45 | + | } | |
| 46 | + | surf.queueUpdate() | |
| 47 | + | if second := surf.queue[1]; len(second.damage) != 1 || second.damage[0].W != 2 || first.damage[0].W != 1 { | |
| 48 | + | t.Fatalf("damage mixed up: %v %v", first.damage, second.damage) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // An explicit-sync commit moves its points into the update: the commit | |
| 53 | + | // itself allocates no commitSync, and a pending one is not overwritten. | |
| 54 | + | func TestCommitSyncStorageIsPerUpdate(t *testing.T) { | |
| 55 | + | dev := newMocksyncobjDevice(t) | |
| 56 | + | dev.EXPECT().signalled(mock.Anything, mock.Anything).Return(true, nil) | |
| 57 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |
| 58 | + | sw, err := newSyncWaiter(func() {}) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | defer sw.pipeR.Close() | |
| 63 | + | defer sw.close() | |
| 64 | + | srv.syncWait = sw | |
| 65 | + | tl := &timeline{dev: dev, handle: 7, alive: true} | |
| 66 | + | surf := &surface{server: srv, sync: &syncState{}} | |
| 67 | + | take := func(point uint64) { | |
| 68 | + | surf.sync.acquire, surf.sync.release = syncPoint{tl: tl, point: point}, syncPoint{tl: tl, point: point + 1} | |
| 69 | + | surf.takeSyncPoints() | |
| 70 | + | surf.queueUpdate() | |
| 71 | + | } | |
| 72 | + | take(10) | |
| 73 | + | take(20) | |
| 74 | + | a, b := surf.queue[0], surf.queue[1] | |
| 75 | + | if a.sync == nil || b.sync == nil || a.sync == b.sync || a.sync.acquire.point != 10 || b.sync.acquire.point != 20 || surf.next.sync != nil { | |
| 76 | + | t.Fatalf("sync points mixed up: %+v %+v pending %+v", a.sync, b.sync, surf.next.sync) | |
| 77 | + | } | |
| 78 | + | // The commit's points live in the surface until queueUpdate: no heap | |
| 79 | + | // commitSync per commit (the mocked device's own allocations would drown | |
| 80 | + | // an AllocsPerRun count here). | |
| 81 | + | surf.sync.acquire, surf.sync.release = syncPoint{tl: tl, point: 30}, syncPoint{tl: tl, point: 31} | |
| 82 | + | surf.takeSyncPoints() | |
| 83 | + | if surf.next.sync != &surf.syncScratch { | |
| 84 | + | t.Fatal("commit points not held in the surface scratch") | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestEmitContentAllocations(t *testing.T) { | |
| 89 | + | s := &Server{ | |
| 90 | + | channels: Channels{Contents: make(chan ports.SurfaceContent)}, | |
| 91 | + | contents: map[ports.WindowID]ports.SurfaceContent{}, | |
| 92 | + | contentSeq: map[ports.WindowID]uint64{}, | |
| 93 | + | damage: map[ports.WindowID][]ports.SeqDamage{}, | |
| 94 | + | contentReady: make(chan struct{}, 1), | |
| 95 | + | } | |
| 96 | + | c := ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}} | |
| 97 | + | var held []ports.SurfaceContent // readers retaining published histories | |
| 98 | + | var snapshots [][]ports.SeqDamage | |
| 99 | + | for range 3 * damageHistoryCap { | |
| 100 | + | s.emitContent(c, damage{full: true}) | |
| 101 | + | got := s.contents[1] | |
| 102 | + | held = append(held, got) | |
| 103 | + | snapshots = append(snapshots, append([]ports.SeqDamage(nil), got.DamageHistory...)) | |
| 104 | + | } | |
| 105 | + | for i, h := range held { | |
| 106 | + | // A published history never changes, nor can its owner append to it. | |
| 107 | + | if len(h.DamageHistory) != len(snapshots[i]) || cap(h.DamageHistory) != len(h.DamageHistory) { | |
| 108 | + | t.Fatalf("history %d: len %d cap %d", i, len(h.DamageHistory), cap(h.DamageHistory)) | |
| 109 | + | } | |
| 110 | + | for j, d := range h.DamageHistory { | |
| 111 | + | if d.Seq != snapshots[i][j].Seq || d.Full != snapshots[i][j].Full { | |
| 112 | + | t.Fatalf("history %d changed at %d: %+v want %+v", i, j, d, snapshots[i][j]) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | if n := len(h.DamageHistory); h.DamageHistory[n-1].Seq != h.Seq || n > damageHistory { | |
| 116 | + | t.Fatalf("history %d: %+v", i, h.DamageHistory) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | allocs := testing.AllocsPerRun(100, func() { s.emitContent(c, damage{full: true}) }) | |
| 120 | + | if allocs != 0 { | |
| 121 | + | t.Errorf("emitContent: %.2f allocs, want 0 (amortized)", allocs) | |
| 122 | + | } | |
| 123 | + | t.Logf("emitContent: %.2f allocs", allocs) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // A reader waiting for newer content is woken by the next publication. | |
| 127 | + | func TestEmitContentWakesWaiter(t *testing.T) { | |
| 128 | + | s := &Server{ | |
| 129 | + | channels: Channels{Contents: make(chan ports.SurfaceContent)}, | |
| 130 | + | contents: map[ports.WindowID]ports.SurfaceContent{}, | |
| 131 | + | contentSeq: map[ports.WindowID]uint64{}, | |
| 132 | + | damage: map[ports.WindowID][]ports.SeqDamage{}, | |
| 133 | + | contentReady: make(chan struct{}, 1), | |
| 134 | + | } | |
| 135 | + | s.contentMu.Lock() | |
| 136 | + | s.contentNotify = make(chan struct{}) | |
| 137 | + | notify := s.contentNotify | |
| 138 | + | s.contentMu.Unlock() | |
| 139 | + | s.emitContent(ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}, damage{full: true}) | |
| 140 | + | select { | |
| 141 | + | case <-notify: | |
| 142 | + | default: | |
| 143 | + | t.Fatal("waiter not woken") | |
| 144 | + | } | |
| 145 | + | } | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // The steady-state hot paths that run for every frame or commit allocate | |||
| 12 | + | // nothing (or one pooled object per damage-history generation). | |||
| 13 | + | ||||
| 14 | + | func TestReleaseHeldAllocations(t *testing.T) { | |||
| 15 | + | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |||
| 16 | + | s := &Server{outputs: []*output{out}, reports: map[string]ports.OutputPresented{}} | |||
| 17 | + | reports := []ports.OutputPresented{{Output: "DP-2", Seen: map[ports.WindowID]uint64{1: 1}}} | |||
| 18 | + | now := time.Unix(100, 0) | |||
| 19 | + | s.releaseHeld(now, reports) | |||
| 20 | + | if allocs := testing.AllocsPerRun(100, func() { s.releaseHeld(now, reports) }); allocs != 0 { | |||
| 21 | + | t.Errorf("releaseHeld: %.1f allocs, want 0", allocs) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestCommitDamageAllocations(t *testing.T) { | |||
| 26 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |||
| 27 | + | surf := &surface{server: srv} | |||
| 28 | + | commit := func() { | |||
| 29 | + | surf.Damage(nil, 0, 0, 4, 4) | |||
| 30 | + | surf.DamageBuffer(nil, 1, 1, 2, 2) | |||
| 31 | + | surf.queueUpdate() | |||
| 32 | + | surf.queue[0].applyGraph() | |||
| 33 | + | } | |||
| 34 | + | commit() | |||
| 35 | + | if allocs := testing.AllocsPerRun(100, commit); allocs != 0 { | |||
| 36 | + | t.Errorf("damage requests + queueUpdate + apply: %.1f allocs, want 0", allocs) | |||
| 37 | + | } | |||
| 38 | + | // The queued update keeps its rects while the next commit collects its own. | |||
| 39 | + | surf.Damage(nil, 0, 0, 1, 1) | |||
| 40 | + | surf.queueUpdate() | |||
| 41 | + | first := surf.queue[0] | |||
| 42 | + | surf.Damage(nil, 5, 5, 2, 2) | |||
| 43 | + | if len(first.damage) != 1 || first.damage[0].W != 1 || len(surf.next.damage) != 1 || surf.next.damage[0].W != 2 { | |||
| 44 | + | t.Fatalf("damage shared between commits: queued %v pending %v", first.damage, surf.next.damage) | |||
| 45 | + | } | |||
| 46 | + | surf.queueUpdate() | |||
| 47 | + | if second := surf.queue[1]; len(second.damage) != 1 || second.damage[0].W != 2 || first.damage[0].W != 1 { | |||
| 48 | + | t.Fatalf("damage mixed up: %v %v", first.damage, second.damage) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // An explicit-sync commit moves its points into the update: the commit | |||
| 53 | + | // itself allocates no commitSync, and a pending one is not overwritten. | |||
| 54 | + | func TestCommitSyncStorageIsPerUpdate(t *testing.T) { | |||
| 55 | + | dev := newMocksyncobjDevice(t) | |||
| 56 | + | dev.EXPECT().signalled(mock.Anything, mock.Anything).Return(true, nil) | |||
| 57 | + | srv := &Server{fifoSurfaces: map[*surface]struct{}{}, frameReady: make(chan struct{}, 1)} | |||
| 58 | + | sw, err := newSyncWaiter(func() {}) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | defer sw.pipeR.Close() | |||
| 63 | + | defer sw.close() | |||
| 64 | + | srv.syncWait = sw | |||
| 65 | + | tl := &timeline{dev: dev, handle: 7, alive: true} | |||
| 66 | + | surf := &surface{server: srv, sync: &syncState{}} | |||
| 67 | + | take := func(point uint64) { | |||
| 68 | + | surf.sync.acquire, surf.sync.release = syncPoint{tl: tl, point: point}, syncPoint{tl: tl, point: point + 1} | |||
| 69 | + | surf.takeSyncPoints() | |||
| 70 | + | surf.queueUpdate() | |||
| 71 | + | } | |||
| 72 | + | take(10) | |||
| 73 | + | take(20) | |||
| 74 | + | a, b := surf.queue[0], surf.queue[1] | |||
| 75 | + | if a.sync == nil || b.sync == nil || a.sync == b.sync || a.sync.acquire.point != 10 || b.sync.acquire.point != 20 || surf.next.sync != nil { | |||
| 76 | + | t.Fatalf("sync points mixed up: %+v %+v pending %+v", a.sync, b.sync, surf.next.sync) | |||
| 77 | + | } | |||
| 78 | + | // The commit's points live in the surface until queueUpdate: no heap | |||
| 79 | + | // commitSync per commit (the mocked device's own allocations would drown | |||
| 80 | + | // an AllocsPerRun count here). | |||
| 81 | + | surf.sync.acquire, surf.sync.release = syncPoint{tl: tl, point: 30}, syncPoint{tl: tl, point: 31} | |||
| 82 | + | surf.takeSyncPoints() | |||
| 83 | + | if surf.next.sync != &surf.syncScratch { | |||
| 84 | + | t.Fatal("commit points not held in the surface scratch") | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestEmitContentAllocations(t *testing.T) { | |||
| 89 | + | s := &Server{ | |||
| 90 | + | channels: Channels{Contents: make(chan ports.SurfaceContent)}, | |||
| 91 | + | contents: map[ports.WindowID]ports.SurfaceContent{}, | |||
| 92 | + | contentSeq: map[ports.WindowID]uint64{}, | |||
| 93 | + | damage: map[ports.WindowID][]ports.SeqDamage{}, | |||
| 94 | + | contentReady: make(chan struct{}, 1), | |||
| 95 | + | } | |||
| 96 | + | c := ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}} | |||
| 97 | + | var held []ports.SurfaceContent // readers retaining published histories | |||
| 98 | + | var snapshots [][]ports.SeqDamage | |||
| 99 | + | for range 3 * damageHistoryCap { | |||
| 100 | + | s.emitContent(c, damage{full: true}) | |||
| 101 | + | got := s.contents[1] | |||
| 102 | + | held = append(held, got) | |||
| 103 | + | snapshots = append(snapshots, append([]ports.SeqDamage(nil), got.DamageHistory...)) | |||
| 104 | + | } | |||
| 105 | + | for i, h := range held { | |||
| 106 | + | // A published history never changes, nor can its owner append to it. | |||
| 107 | + | if len(h.DamageHistory) != len(snapshots[i]) || cap(h.DamageHistory) != len(h.DamageHistory) { | |||
| 108 | + | t.Fatalf("history %d: len %d cap %d", i, len(h.DamageHistory), cap(h.DamageHistory)) | |||
| 109 | + | } | |||
| 110 | + | for j, d := range h.DamageHistory { | |||
| 111 | + | if d.Seq != snapshots[i][j].Seq || d.Full != snapshots[i][j].Full { | |||
| 112 | + | t.Fatalf("history %d changed at %d: %+v want %+v", i, j, d, snapshots[i][j]) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | if n := len(h.DamageHistory); h.DamageHistory[n-1].Seq != h.Seq || n > damageHistory { | |||
| 116 | + | t.Fatalf("history %d: %+v", i, h.DamageHistory) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | allocs := testing.AllocsPerRun(100, func() { s.emitContent(c, damage{full: true}) }) | |||
| 120 | + | if allocs != 0 { | |||
| 121 | + | t.Errorf("emitContent: %.2f allocs, want 0 (amortized)", allocs) | |||
| 122 | + | } | |||
| 123 | + | t.Logf("emitContent: %.2f allocs", allocs) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // A reader waiting for newer content is woken by the next publication. | |||
| 127 | + | func TestEmitContentWakesWaiter(t *testing.T) { | |||
| 128 | + | s := &Server{ | |||
| 129 | + | channels: Channels{Contents: make(chan ports.SurfaceContent)}, | |||
| 130 | + | contents: map[ports.WindowID]ports.SurfaceContent{}, | |||
| 131 | + | contentSeq: map[ports.WindowID]uint64{}, | |||
| 132 | + | damage: map[ports.WindowID][]ports.SeqDamage{}, | |||
| 133 | + | contentReady: make(chan struct{}, 1), | |||
| 134 | + | } | |||
| 135 | + | s.contentMu.Lock() | |||
| 136 | + | s.contentNotify = make(chan struct{}) | |||
| 137 | + | notify := s.contentNotify | |||
| 138 | + | s.contentMu.Unlock() | |||
| 139 | + | s.emitContent(ports.SurfaceContent{ID: 1, Solid: &ports.SolidColor{}}, damage{full: true}) | |||
| 140 | + | select { | |||
| 141 | + | case <-notify: | |||
| 142 | + | default: | |||
| 143 | + | t.Fatal("waiter not woken") | |||
| 144 | + | } | |||
| 145 | + | } | |||
M internal/adapters/wayland/fifo.go +15 -4
| @@ -209,6 +209,7 @@ type update struct { | |||
| 209 | 209 | deps []*update | |
| 210 | 210 | owner *surface | |
| 211 | 211 | implicit [4]*syncWait | |
| 212 | + | syncStore commitSync // storage of sync when the commit had explicit-sync points | |
| 212 | 213 | prev *update | |
| 213 | 214 | synced, bound bool | |
| 214 | 215 | xdg *xdgSurface | |
| @@ -237,8 +238,8 @@ var updatePool sync.Pool | |||
| 237 | 238 | func (u *update) releaseRef() { | |
| 238 | 239 | u.refs-- | |
| 239 | 240 | if u.refs == 0 && u.retired { | |
| 240 | - | deps := u.deps[:0] | |
| 241 | - | *u = update{deps: deps} | |
| 241 | + | deps, dmg, bufDmg := u.deps[:0], u.damage[:0], u.bufDamage[:0] | |
| 242 | + | *u = update{deps: deps, damage: dmg, bufDamage: bufDmg} | |
| 242 | 243 | updatePool.Put(u) | |
| 243 | 244 | } | |
| 244 | 245 | } | |
| @@ -257,7 +258,7 @@ func (u *update) retireUpdate() { | |||
| 257 | 258 | dep.releaseRef() | |
| 258 | 259 | } | |
| 259 | 260 | if u.refs == 0 { | |
| 260 | - | *u = update{deps: u.deps} | |
| 261 | + | *u = update{deps: u.deps, damage: u.damage[:0], bufDamage: u.bufDamage[:0]} | |
| 261 | 262 | updatePool.Put(u) | |
| 262 | 263 | } | |
| 263 | 264 | } | |
| @@ -346,9 +347,19 @@ func (s *surface) queueUpdate() { | |||
| 346 | 347 | if u == nil { | |
| 347 | 348 | u = new(update) | |
| 348 | 349 | } | |
| 349 | - | deps := u.deps[:0] | |
| 350 | + | deps, dmg, bufDmg := u.deps[:0], u.damage[:0], u.bufDamage[:0] | |
| 350 | 351 | *u = s.takePending() | |
| 351 | 352 | u.deps = deps | |
| 353 | + | // The pooled update brings the capacity a retired commit's damage had; | |
| 354 | + | // the surface collects the next commit's rects in it. The update keeps | |
| 355 | + | // the rects of this commit until it retires. | |
| 356 | + | s.next.damage, s.next.bufDamage = dmg, bufDmg | |
| 357 | + | if u.sync != nil && u.sync == &s.syncScratch { | |
| 358 | + | // The update owns its points from here: the surface's scratch is | |
| 359 | + | // free for the next commit while this one waits in the queue. | |
| 360 | + | u.syncStore, s.syncScratch = s.syncScratch, commitSync{} | |
| 361 | + | u.sync = &u.syncStore | |
| 362 | + | } | |
| 352 | 363 | u.owner, u.synced = s, s.effectivelySynced() | |
| 353 | 364 | if s.server.syncWait != nil && u.attached && u.buffer != nil && u.sync == nil { | |
| 354 | 365 | if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok { | |
| @@ -209,6 +209,7 @@ type update struct { | |||||
| 209 | deps []*update | 209 | deps []*update | ||
| 210 | owner *surface | 210 | owner *surface | ||
| 211 | implicit [4]*syncWait | 211 | implicit [4]*syncWait | ||
| 212 | + | syncStore commitSync // storage of sync when the commit had explicit-sync points | |||
| 212 | prev *update | 213 | prev *update | ||
| 213 | synced, bound bool | 214 | synced, bound bool | ||
| 214 | xdg *xdgSurface | 215 | xdg *xdgSurface | ||
| @@ -237,8 +238,8 @@ var updatePool sync.Pool | |||||
| 237 | func (u *update) releaseRef() { | 238 | func (u *update) releaseRef() { | ||
| 238 | u.refs-- | 239 | u.refs-- | ||
| 239 | if u.refs == 0 && u.retired { | 240 | if u.refs == 0 && u.retired { | ||
| 240 | - | deps := u.deps[:0] | 241 | + | deps, dmg, bufDmg := u.deps[:0], u.damage[:0], u.bufDamage[:0] |
| 241 | - | *u = update{deps: deps} | 242 | + | *u = update{deps: deps, damage: dmg, bufDamage: bufDmg} |
| 242 | updatePool.Put(u) | 243 | updatePool.Put(u) | ||
| 243 | } | 244 | } | ||
| 244 | } | 245 | } | ||
| @@ -257,7 +258,7 @@ func (u *update) retireUpdate() { | |||||
| 257 | dep.releaseRef() | 258 | dep.releaseRef() | ||
| 258 | } | 259 | } | ||
| 259 | if u.refs == 0 { | 260 | if u.refs == 0 { | ||
| 260 | - | *u = update{deps: u.deps} | 261 | + | *u = update{deps: u.deps, damage: u.damage[:0], bufDamage: u.bufDamage[:0]} |
| 261 | updatePool.Put(u) | 262 | updatePool.Put(u) | ||
| 262 | } | 263 | } | ||
| 263 | } | 264 | } | ||
| @@ -346,9 +347,19 @@ func (s *surface) queueUpdate() { | |||||
| 346 | if u == nil { | 347 | if u == nil { | ||
| 347 | u = new(update) | 348 | u = new(update) | ||
| 348 | } | 349 | } | ||
| 349 | - | deps := u.deps[:0] | 350 | + | deps, dmg, bufDmg := u.deps[:0], u.damage[:0], u.bufDamage[:0] |
| 350 | *u = s.takePending() | 351 | *u = s.takePending() | ||
| 351 | u.deps = deps | 352 | u.deps = deps | ||
| 353 | + | // The pooled update brings the capacity a retired commit's damage had; | |||
| 354 | + | // the surface collects the next commit's rects in it. The update keeps | |||
| 355 | + | // the rects of this commit until it retires. | |||
| 356 | + | s.next.damage, s.next.bufDamage = dmg, bufDmg | |||
| 357 | + | if u.sync != nil && u.sync == &s.syncScratch { | |||
| 358 | + | // The update owns its points from here: the surface's scratch is | |||
| 359 | + | // free for the next commit while this one waits in the queue. | |||
| 360 | + | u.syncStore, s.syncScratch = s.syncScratch, commitSync{} | |||
| 361 | + | u.sync = &u.syncStore | |||
| 362 | + | } | |||
| 352 | u.owner, u.synced = s, s.effectivelySynced() | 363 | u.owner, u.synced = s, s.effectivelySynced() | ||
| 353 | if s.server.syncWait != nil && u.attached && u.buffer != nil && u.sync == nil { | 364 | if s.server.syncWait != nil && u.attached && u.buffer != nil && u.sync == nil { | ||
| 354 | if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok { | 365 | if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok { | ||
M internal/adapters/wayland/release.go +4 -1
| @@ -95,7 +95,7 @@ func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) boo | |||
| 95 | 95 | for _, r := range reports { | |
| 96 | 96 | s.reports[r.Output] = r | |
| 97 | 97 | } | |
| 98 | - | live := make([]ports.OutputPresented, 0, len(s.outputs)) | |
| 98 | + | live := s.heldLive[:0] | |
| 99 | 99 | for name := range s.reports { | |
| 100 | 100 | if s.outputByNameExact(name) == nil { | |
| 101 | 101 | delete(s.reports, name) | |
| @@ -104,6 +104,8 @@ func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) boo | |||
| 104 | 104 | for _, r := range s.reports { | |
| 105 | 105 | live = append(live, r) | |
| 106 | 106 | } | |
| 107 | + | // keepHeld only reads live: the scratch holds nothing across calls. | |
| 108 | + | s.heldLive = live | |
| 107 | 109 | kept := s.held[:0] | |
| 108 | 110 | for _, h := range s.held { | |
| 109 | 111 | if s.bufferReferenced(h.res) || keepHeld(h, live, now) { | |
| @@ -114,6 +116,7 @@ func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) boo | |||
| 114 | 116 | } | |
| 115 | 117 | clear(s.held[len(kept):]) | |
| 116 | 118 | s.held = kept | |
| 119 | + | clear(live) // the reports carry maps: do not retain them | |
| 117 | 120 | return len(s.held) > 0 | |
| 118 | 121 | } | |
| 119 | 122 | ||
| @@ -95,7 +95,7 @@ func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) boo | |||||
| 95 | for _, r := range reports { | 95 | for _, r := range reports { | ||
| 96 | s.reports[r.Output] = r | 96 | s.reports[r.Output] = r | ||
| 97 | } | 97 | } | ||
| 98 | - | live := make([]ports.OutputPresented, 0, len(s.outputs)) | 98 | + | live := s.heldLive[:0] |
| 99 | for name := range s.reports { | 99 | for name := range s.reports { | ||
| 100 | if s.outputByNameExact(name) == nil { | 100 | if s.outputByNameExact(name) == nil { | ||
| 101 | delete(s.reports, name) | 101 | delete(s.reports, name) | ||
| @@ -104,6 +104,8 @@ func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) boo | |||||
| 104 | for _, r := range s.reports { | 104 | for _, r := range s.reports { | ||
| 105 | live = append(live, r) | 105 | live = append(live, r) | ||
| 106 | } | 106 | } | ||
| 107 | + | // keepHeld only reads live: the scratch holds nothing across calls. | |||
| 108 | + | s.heldLive = live | |||
| 107 | kept := s.held[:0] | 109 | kept := s.held[:0] | ||
| 108 | for _, h := range s.held { | 110 | for _, h := range s.held { | ||
| 109 | if s.bufferReferenced(h.res) || keepHeld(h, live, now) { | 111 | if s.bufferReferenced(h.res) || keepHeld(h, live, now) { | ||
| @@ -114,6 +116,7 @@ func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) boo | |||||
| 114 | } | 116 | } | ||
| 115 | clear(s.held[len(kept):]) | 117 | clear(s.held[len(kept):]) | ||
| 116 | s.held = kept | 118 | s.held = kept | ||
| 119 | + | clear(live) // the reports carry maps: do not retain them | |||
| 117 | return len(s.held) > 0 | 120 | return len(s.held) > 0 | ||
| 118 | } | 121 | } | ||
| 119 | 122 | ||||
M internal/adapters/wayland/server.go +31 -12
| @@ -164,10 +164,12 @@ type Server struct { | |||
| 164 | 164 | frameReports []ports.OutputPresented | |
| 165 | 165 | // held buffers wait until no output reads them (release.go); reports | |
| 166 | 166 | // are the outputs' latest ports.OutputPresented. | |
| 167 | - | held []heldBuffer | |
| 168 | - | reports map[string]ports.OutputPresented | |
| 169 | - | frames uint64 | |
| 170 | - | started time.Time | |
| 167 | + | held []heldBuffer | |
| 168 | + | // heldLive is releaseHeld's scratch (display goroutine only). | |
| 169 | + | heldLive []ports.OutputPresented | |
| 170 | + | reports map[string]ports.OutputPresented | |
| 171 | + | frames uint64 | |
| 172 | + | started time.Time | |
| 171 | 173 | // fifoSurfaces have a fifo barrier or queued commits (fifo.go); | |
| 172 | 174 | // lastFlip is each output's latest page flip. | |
| 173 | 175 | fifoSurfaces map[*surface]struct{} | |
| @@ -335,7 +337,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 335 | 337 | dataSources: map[*server.Resource]*clipSource{}, | |
| 336 | 338 | primarySources: map[*server.Resource]*clipSource{}, | |
| 337 | 339 | controlSources: map[*server.Resource]*clipSource{}, | |
| 338 | - | contentNotify: make(chan struct{}), | |
| 339 | 340 | regions: map[*server.Resource]*region{}, | |
| 340 | 341 | relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, | |
| 341 | 342 | gestures: map[server.Client][]*gestureObject{}, | |
| @@ -570,6 +571,10 @@ func (s *Server) forwardOutputApplied(ctx context.Context) { | |||
| 570 | 571 | // enough for a renderer target a few frames old. | |
| 571 | 572 | const damageHistory = 4 | |
| 572 | 573 | ||
| 574 | + | // damageHistoryCap is the capacity of a history's backing array: one | |
| 575 | + | // allocation serves damageHistoryCap-damageHistory+1 publications. | |
| 576 | + | const damageHistoryCap = 8 * damageHistory | |
| 577 | + | ||
| 573 | 578 | // forwardOutputFormats applies the outputs' scanout formats on the | |
| 574 | 579 | // display goroutine. | |
| 575 | 580 | func (s *Server) forwardOutputFormats(ctx context.Context) { | |
| @@ -639,23 +644,34 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||
| 639 | 644 | s.contentMu.Lock() | |
| 640 | 645 | s.contentSeq[c.ID]++ | |
| 641 | 646 | c.Seq = s.contentSeq[c.ID] | |
| 642 | - | // Copy-on-write: previous publications can still be in an output's hands. | |
| 647 | + | // Published histories are immutable: an output may hold one for as long | |
| 648 | + | // as it likes. The history grows inside one backing array, appended past | |
| 649 | + | // the end of every published slice (never over an element a reader can | |
| 650 | + | // see), and moves to a fresh array only when that one is full. Readers | |
| 651 | + | // get a capacity-clipped slice, so they cannot append into the spare. | |
| 643 | 652 | old := s.damage[c.ID] | |
| 644 | 653 | if len(old) >= damageHistory { | |
| 645 | 654 | old = old[len(old)-damageHistory+1:] | |
| 646 | 655 | } | |
| 647 | - | h := make([]ports.SeqDamage, len(old)+1) | |
| 648 | - | copy(h, old) | |
| 649 | - | h[len(old)] = ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects} | |
| 656 | + | if len(old) == cap(old) { | |
| 657 | + | grown := make([]ports.SeqDamage, len(old), damageHistoryCap) | |
| 658 | + | copy(grown, old) | |
| 659 | + | old = grown | |
| 660 | + | } | |
| 661 | + | h := append(old, ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects}) | |
| 650 | 662 | if c.Empty() { | |
| 651 | 663 | delete(s.damage, c.ID) | |
| 652 | 664 | } else { | |
| 653 | 665 | s.damage[c.ID] = h | |
| 654 | 666 | } | |
| 655 | - | c.DamageHistory = h | |
| 667 | + | c.DamageHistory = h[:len(h):len(h)] | |
| 656 | 668 | s.contents[c.ID] = c | |
| 657 | - | close(s.contentNotify) | |
| 658 | - | s.contentNotify = make(chan struct{}) | |
| 669 | + | if s.contentNotify != nil { | |
| 670 | + | // Only forwardContents waits on it, and it creates the channel when | |
| 671 | + | // it does: no channel is allocated per commit. | |
| 672 | + | close(s.contentNotify) | |
| 673 | + | s.contentNotify = nil | |
| 674 | + | } | |
| 659 | 675 | s.contentMu.Unlock() | |
| 660 | 676 | select { | |
| 661 | 677 | case s.contentReady <- struct{}{}: | |
| @@ -676,6 +692,9 @@ func (s *Server) forwardContents(ctx context.Context) { | |||
| 676 | 692 | seq = s.contentSeq[id] | |
| 677 | 693 | break | |
| 678 | 694 | } | |
| 695 | + | if s.contentNotify == nil { | |
| 696 | + | s.contentNotify = make(chan struct{}) | |
| 697 | + | } | |
| 679 | 698 | notify = s.contentNotify | |
| 680 | 699 | s.contentMu.Unlock() | |
| 681 | 700 | if !found { | |
| @@ -164,10 +164,12 @@ type Server struct { | |||||
| 164 | frameReports []ports.OutputPresented | 164 | frameReports []ports.OutputPresented | ||
| 165 | // held buffers wait until no output reads them (release.go); reports | 165 | // held buffers wait until no output reads them (release.go); reports | ||
| 166 | // are the outputs' latest ports.OutputPresented. | 166 | // are the outputs' latest ports.OutputPresented. | ||
| 167 | - | held []heldBuffer | 167 | + | held []heldBuffer |
| 168 | - | reports map[string]ports.OutputPresented | 168 | + | // heldLive is releaseHeld's scratch (display goroutine only). |
| 169 | - | frames uint64 | 169 | + | heldLive []ports.OutputPresented |
| 170 | - | started time.Time | 170 | + | reports map[string]ports.OutputPresented |
| 171 | + | frames uint64 | |||
| 172 | + | started time.Time | |||
| 171 | // fifoSurfaces have a fifo barrier or queued commits (fifo.go); | 173 | // fifoSurfaces have a fifo barrier or queued commits (fifo.go); | ||
| 172 | // lastFlip is each output's latest page flip. | 174 | // lastFlip is each output's latest page flip. | ||
| 173 | fifoSurfaces map[*surface]struct{} | 175 | fifoSurfaces map[*surface]struct{} | ||
| @@ -335,7 +337,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 335 | dataSources: map[*server.Resource]*clipSource{}, | 337 | dataSources: map[*server.Resource]*clipSource{}, | ||
| 336 | primarySources: map[*server.Resource]*clipSource{}, | 338 | primarySources: map[*server.Resource]*clipSource{}, | ||
| 337 | controlSources: map[*server.Resource]*clipSource{}, | 339 | controlSources: map[*server.Resource]*clipSource{}, | ||
| 338 | - | contentNotify: make(chan struct{}), | |||
| 339 | regions: map[*server.Resource]*region{}, | 340 | regions: map[*server.Resource]*region{}, | ||
| 340 | relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, | 341 | relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, | ||
| 341 | gestures: map[server.Client][]*gestureObject{}, | 342 | gestures: map[server.Client][]*gestureObject{}, | ||
| @@ -570,6 +571,10 @@ func (s *Server) forwardOutputApplied(ctx context.Context) { | |||||
| 570 | // enough for a renderer target a few frames old. | 571 | // enough for a renderer target a few frames old. | ||
| 571 | const damageHistory = 4 | 572 | const damageHistory = 4 | ||
| 572 | 573 | ||||
| 574 | + | // damageHistoryCap is the capacity of a history's backing array: one | |||
| 575 | + | // allocation serves damageHistoryCap-damageHistory+1 publications. | |||
| 576 | + | const damageHistoryCap = 8 * damageHistory | |||
| 577 | + | ||||
| 573 | // forwardOutputFormats applies the outputs' scanout formats on the | 578 | // forwardOutputFormats applies the outputs' scanout formats on the | ||
| 574 | // display goroutine. | 579 | // display goroutine. | ||
| 575 | func (s *Server) forwardOutputFormats(ctx context.Context) { | 580 | func (s *Server) forwardOutputFormats(ctx context.Context) { | ||
| @@ -639,23 +644,34 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||||
| 639 | s.contentMu.Lock() | 644 | s.contentMu.Lock() | ||
| 640 | s.contentSeq[c.ID]++ | 645 | s.contentSeq[c.ID]++ | ||
| 641 | c.Seq = s.contentSeq[c.ID] | 646 | c.Seq = s.contentSeq[c.ID] | ||
| 642 | - | // Copy-on-write: previous publications can still be in an output's hands. | 647 | + | // Published histories are immutable: an output may hold one for as long |
| 648 | + | // as it likes. The history grows inside one backing array, appended past | |||
| 649 | + | // the end of every published slice (never over an element a reader can | |||
| 650 | + | // see), and moves to a fresh array only when that one is full. Readers | |||
| 651 | + | // get a capacity-clipped slice, so they cannot append into the spare. | |||
| 643 | old := s.damage[c.ID] | 652 | old := s.damage[c.ID] | ||
| 644 | if len(old) >= damageHistory { | 653 | if len(old) >= damageHistory { | ||
| 645 | old = old[len(old)-damageHistory+1:] | 654 | old = old[len(old)-damageHistory+1:] | ||
| 646 | } | 655 | } | ||
| 647 | - | h := make([]ports.SeqDamage, len(old)+1) | 656 | + | if len(old) == cap(old) { |
| 648 | - | copy(h, old) | 657 | + | grown := make([]ports.SeqDamage, len(old), damageHistoryCap) |
| 649 | - | h[len(old)] = ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects} | 658 | + | copy(grown, old) |
| 659 | + | old = grown | |||
| 660 | + | } | |||
| 661 | + | h := append(old, ports.SeqDamage{Seq: c.Seq, Full: d.full, Rects: d.rects}) | |||
| 650 | if c.Empty() { | 662 | if c.Empty() { | ||
| 651 | delete(s.damage, c.ID) | 663 | delete(s.damage, c.ID) | ||
| 652 | } else { | 664 | } else { | ||
| 653 | s.damage[c.ID] = h | 665 | s.damage[c.ID] = h | ||
| 654 | } | 666 | } | ||
| 655 | - | c.DamageHistory = h | 667 | + | c.DamageHistory = h[:len(h):len(h)] |
| 656 | s.contents[c.ID] = c | 668 | s.contents[c.ID] = c | ||
| 657 | - | close(s.contentNotify) | 669 | + | if s.contentNotify != nil { |
| 658 | - | s.contentNotify = make(chan struct{}) | 670 | + | // Only forwardContents waits on it, and it creates the channel when |
| 671 | + | // it does: no channel is allocated per commit. | |||
| 672 | + | close(s.contentNotify) | |||
| 673 | + | s.contentNotify = nil | |||
| 674 | + | } | |||
| 659 | s.contentMu.Unlock() | 675 | s.contentMu.Unlock() | ||
| 660 | select { | 676 | select { | ||
| 661 | case s.contentReady <- struct{}{}: | 677 | case s.contentReady <- struct{}{}: | ||
| @@ -676,6 +692,9 @@ func (s *Server) forwardContents(ctx context.Context) { | |||||
| 676 | seq = s.contentSeq[id] | 692 | seq = s.contentSeq[id] | ||
| 677 | break | 693 | break | ||
| 678 | } | 694 | } | ||
| 695 | + | if s.contentNotify == nil { | |||
| 696 | + | s.contentNotify = make(chan struct{}) | |||
| 697 | + | } | |||
| 679 | notify = s.contentNotify | 698 | notify = s.contentNotify | ||
| 680 | s.contentMu.Unlock() | 699 | s.contentMu.Unlock() | ||
| 681 | if !found { | 700 | if !found { | ||
M internal/adapters/wayland/surface.go +3 -0
| @@ -33,6 +33,9 @@ type surface struct { | |||
| 33 | 33 | committedViewport viewportState // value snapshot; no allocation on viewport commits | |
| 34 | 34 | // next is the state requested since the last commit (see pendingCommit). | |
| 35 | 35 | next pendingCommit | |
| 36 | + | // syncScratch holds next.sync between takeSyncPoints and queueUpdate, | |
| 37 | + | // which moves it into the update. | |
| 38 | + | syncScratch commitSync | |
| 36 | 39 | // bufferScale, transform and opaque are committed state. formatOpaque is | |
| 37 | 40 | // the current buffer's own opacity (x formats). | |
| 38 | 41 | bufferScale int | |
| @@ -33,6 +33,9 @@ type surface struct { | |||||
| 33 | committedViewport viewportState // value snapshot; no allocation on viewport commits | 33 | committedViewport viewportState // value snapshot; no allocation on viewport commits | ||
| 34 | // next is the state requested since the last commit (see pendingCommit). | 34 | // next is the state requested since the last commit (see pendingCommit). | ||
| 35 | next pendingCommit | 35 | next pendingCommit | ||
| 36 | + | // syncScratch holds next.sync between takeSyncPoints and queueUpdate, | |||
| 37 | + | // which moves it into the update. | |||
| 38 | + | syncScratch commitSync | |||
| 36 | // bufferScale, transform and opaque are committed state. formatOpaque is | 39 | // bufferScale, transform and opaque are committed state. formatOpaque is | ||
| 37 | // the current buffer's own opacity (x formats). | 40 | // the current buffer's own opacity (x formats). | ||
| 38 | bufferScale int | 41 | bufferScale int | ||
M internal/adapters/wayland/syncobj.go +4 -1
| @@ -487,7 +487,10 @@ func (s *surface) takeSyncPoints() { | |||
| 487 | 487 | if st == nil || !st.acquire.set() { | |
| 488 | 488 | return | |
| 489 | 489 | } | |
| 490 | - | cs := &commitSync{acquire: st.acquire, release: st.release} | |
| 490 | + | // The commit's points wait in the surface until queueUpdate moves them | |
| 491 | + | // into the update: no allocation per commit. | |
| 492 | + | cs := &s.syncScratch | |
| 493 | + | *cs = commitSync{acquire: st.acquire, release: st.release} | |
| 491 | 494 | st.acquire, st.release = syncPoint{}, syncPoint{} | |
| 492 | 495 | cs.acquire.tl.uses++ | |
| 493 | 496 | cs.release.tl.uses++ | |
| @@ -487,7 +487,10 @@ func (s *surface) takeSyncPoints() { | |||||
| 487 | if st == nil || !st.acquire.set() { | 487 | if st == nil || !st.acquire.set() { | ||
| 488 | return | 488 | return | ||
| 489 | } | 489 | } | ||
| 490 | - | cs := &commitSync{acquire: st.acquire, release: st.release} | 490 | + | // The commit's points wait in the surface until queueUpdate moves them |
| 491 | + | // into the update: no allocation per commit. | |||
| 492 | + | cs := &s.syncScratch | |||
| 493 | + | *cs = commitSync{acquire: st.acquire, release: st.release} | |||
| 491 | st.acquire, st.release = syncPoint{}, syncPoint{} | 494 | st.acquire, st.release = syncPoint{}, syncPoint{} | ||
| 492 | cs.acquire.tl.uses++ | 495 | cs.acquire.tl.uses++ | ||
| 493 | cs.release.tl.uses++ | 496 | cs.release.tl.uses++ | ||