detached 52e9635

perf(core): guard publish, overview and partial-redraw allocations, reuse damage scratch

commit
52e963535ab44dbb0c53325c4e4c150f68ebf91d
author
brice · · 2026-10-04 20:13 UTC
committer
brice · · 2026-10-04 20:13 UTC
parent
afb3cdc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +151 -48
M Makefile +1 -1
@@ -60,7 +60,7 @@ arch:
6060 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
6161 # Fast, mandatory guards for tiled content publications and SHM copies.
6262 perf-check:
63- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
63+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
6464 check: vet test arch fakes-check log-check perf-check
6565
6666 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -60,7 +60,7 @@ arch:
60 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...60 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
61 # Fast, mandatory guards for tiled content publications and SHM copies.61 # Fast, mandatory guards for tiled content publications and SHM copies.
62 perf-check:62 perf-check:
63- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=163+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
64 check: vet test arch fakes-check log-check perf-check64 check: vet test arch fakes-check log-check perf-check
65 65
66 # On-demand SDR/HDR colour check with examples/testpattern on a headless66 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/adapters/vulkan/damage.go +9 -6
@@ -22,6 +22,8 @@ type damageRegion struct {
2222 seen map[ports.WindowID]bool
2323 bound image.Rectangle
2424 drawn map[ports.WindowID]heldWindow
25+ // rects is scratch for a content's damage rects (reused across frames).
26+ rects []ports.Rect
2527 }
2628
2729 // heldWindow is a window a target holds: its content Seq and rect.
@@ -50,7 +52,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle
5052 r.damageDrawn = make(map[ports.WindowID]heldWindow)
5153 }
5254 clear(r.damageDrawn)
53- r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds}
55+ r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]}
5456 if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
5557 r.damage.held = tg
5658 }
@@ -86,13 +88,12 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im
8688 d.add(rect)
8789 return
8890 }
89- rects, known := c.DamageSince(held)
91+ var known bool
92+ d.rects, known = c.AppendDamageSince(d.rects[:0], held)
9093 if !known {
9194 d.add(rect)
92- return
9395 }
94- // Mapped by content, called next: nothing more here.
95- _ = rects
96+ // Otherwise mapped by content, called next: nothing more here.
9697 }
9798
9899 // content maps a root surface's buffer damage onto full (where its w×h
@@ -107,10 +108,12 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
107108 if !ok || held == c.Seq || len(c.Children) > 0 {
108109 return
109110 }
110- rects, known := c.DamageSince(held)
111+ var known bool
112+ d.rects, known = c.AppendDamageSince(d.rects[:0], held)
111113 if !known || c.Width <= 0 || c.Height <= 0 {
112114 return
113115 }
116+ rects := d.rects
114117 if c.Transform != 0 {
115118 d.add(dst)
116119 return
@@ -22,6 +22,8 @@ type damageRegion struct {
22 seen map[ports.WindowID]bool22 seen map[ports.WindowID]bool
23 bound image.Rectangle23 bound image.Rectangle
24 drawn map[ports.WindowID]heldWindow24 drawn map[ports.WindowID]heldWindow
25+ // rects is scratch for a content's damage rects (reused across frames).
26+ rects []ports.Rect
25 }27 }
26 28
27 // heldWindow is a window a target holds: its content Seq and rect.29 // heldWindow is a window a target holds: its content Seq and rect.
@@ -50,7 +52,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle
50 r.damageDrawn = make(map[ports.WindowID]heldWindow)52 r.damageDrawn = make(map[ports.WindowID]heldWindow)
51 }53 }
52 clear(r.damageDrawn)54 clear(r.damageDrawn)
53- r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds}55+ r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]}
54 if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {56 if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
55 r.damage.held = tg57 r.damage.held = tg
56 }58 }
@@ -86,13 +88,12 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im
86 d.add(rect)88 d.add(rect)
87 return89 return
88 }90 }
89- rects, known := c.DamageSince(held)91+ var known bool
92+ d.rects, known = c.AppendDamageSince(d.rects[:0], held)
90 if !known {93 if !known {
91 d.add(rect)94 d.add(rect)
92- return
93 }95 }
94- // Mapped by content, called next: nothing more here.96+ // Otherwise mapped by content, called next: nothing more here.
95- _ = rects
96 }97 }
97 98
98 // content maps a root surface's buffer damage onto full (where its w×h99 // content maps a root surface's buffer damage onto full (where its w×h
@@ -107,10 +108,12 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
107 if !ok || held == c.Seq || len(c.Children) > 0 {108 if !ok || held == c.Seq || len(c.Children) > 0 {
108 return109 return
109 }110 }
110- rects, known := c.DamageSince(held)111+ var known bool
112+ d.rects, known = c.AppendDamageSince(d.rects[:0], held)
111 if !known || c.Width <= 0 || c.Height <= 0 {113 if !known || c.Width <= 0 || c.Height <= 0 {
112 return114 return
113 }115 }
116+ rects := d.rects
114 if c.Transform != 0 {117 if c.Transform != 0 {
115 d.add(dst)118 d.add(dst)
116 return119 return
M internal/adapters/vulkan/scene_perf_test.go +26 -10
@@ -62,20 +62,36 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
6262 if allocs := testing.AllocsPerRun(50, frameDraws); allocs != 0 {
6363 t.Errorf("moving window draws: %.1f allocs/frame, want 0", allocs)
6464 }
65- // A content change under an unchanged scene Seq: partial redraw
66- // from the window damage, clipped to the changed region.
67- changed := [2]ports.SurfaceContent{content, content}
68- changed[1].Seq, changed[1].Version = 2, 2
69- same := scene
70- same.Seq = r.last.sceneSeq
65+ // A content change under an unchanged scene Seq: a partial
66+ // redraw clipped to the changed buffer region, replicating the
67+ // partial sequence of Render. The stand-in target holds the
68+ // previous content Seq of the window, and is never updated, so
69+ // every run is damaged.
70+ dc := content
71+ dc.Seq, dc.Version = 2, 2
72+ dc.DamageHistory = []ports.SeqDamage{{Seq: 2, Rects: []ports.Rect{{X: 1, Y: 1, W: 2, H: 2}}}}
73+ pcontents := map[ports.WindowID]ports.SurfaceContent{1: dc}
74+ held := &target{valid: true, sceneSeq: scene.Seq, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: image.Rect(0, 0, r.width, r.height)}}}
75+ var area image.Rectangle
76+ var whole bool
7177 partial := func() {
72- n++
73- contents[1] = changed[n%2]
74- _ = r.draws(same, contents, r.frameDamage(r.last, same, bounds))
78+ dmg := r.frameDamage(held, scene, bounds)
79+ ds := r.draws(scene, pcontents, dmg)
80+ dmg.finish()
81+ ds = append(ds, draw{})
82+ copy(ds[1:], ds[:len(ds)-1])
83+ ds[0] = r.fillDraw(dmg.area, parseColor(scene.Background))
84+ ds = dmg.clip(ds)
85+ r.scratchDraws = ds
86+ r.orient(ds, scene.Transform)
87+ area, whole = dmg.area, dmg.all()
7588 }
7689 partial()
90+ if whole || area.Empty() || area.Dx()*area.Dy() >= bounds.Dx()*bounds.Dy() {
91+ t.Fatalf("not a partial redraw: all %v, area %v of %v", whole, area, bounds)
92+ }
7793 if allocs := testing.AllocsPerRun(50, partial); allocs != 0 {
78- t.Errorf("content-damage draws: %.1f allocs/frame, want 0", allocs)
94+ t.Errorf("partial redraw draws: %.1f allocs/frame, want 0", allocs)
7995 }
8096 })
8197 }
@@ -62,20 +62,36 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
62 if allocs := testing.AllocsPerRun(50, frameDraws); allocs != 0 {62 if allocs := testing.AllocsPerRun(50, frameDraws); allocs != 0 {
63 t.Errorf("moving window draws: %.1f allocs/frame, want 0", allocs)63 t.Errorf("moving window draws: %.1f allocs/frame, want 0", allocs)
64 }64 }
65- // A content change under an unchanged scene Seq: partial redraw65+ // A content change under an unchanged scene Seq: a partial
66- // from the window damage, clipped to the changed region.66+ // redraw clipped to the changed buffer region, replicating the
67- changed := [2]ports.SurfaceContent{content, content}67+ // partial sequence of Render. The stand-in target holds the
68- changed[1].Seq, changed[1].Version = 2, 268+ // previous content Seq of the window, and is never updated, so
69- same := scene69+ // every run is damaged.
70- same.Seq = r.last.sceneSeq70+ dc := content
71+ dc.Seq, dc.Version = 2, 2
72+ dc.DamageHistory = []ports.SeqDamage{{Seq: 2, Rects: []ports.Rect{{X: 1, Y: 1, W: 2, H: 2}}}}
73+ pcontents := map[ports.WindowID]ports.SurfaceContent{1: dc}
74+ held := &target{valid: true, sceneSeq: scene.Seq, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: image.Rect(0, 0, r.width, r.height)}}}
75+ var area image.Rectangle
76+ var whole bool
71 partial := func() {77 partial := func() {
72- n++78+ dmg := r.frameDamage(held, scene, bounds)
73- contents[1] = changed[n%2]79+ ds := r.draws(scene, pcontents, dmg)
74- _ = r.draws(same, contents, r.frameDamage(r.last, same, bounds))80+ dmg.finish()
81+ ds = append(ds, draw{})
82+ copy(ds[1:], ds[:len(ds)-1])
83+ ds[0] = r.fillDraw(dmg.area, parseColor(scene.Background))
84+ ds = dmg.clip(ds)
85+ r.scratchDraws = ds
86+ r.orient(ds, scene.Transform)
87+ area, whole = dmg.area, dmg.all()
75 }88 }
76 partial()89 partial()
90+ if whole || area.Empty() || area.Dx()*area.Dy() >= bounds.Dx()*bounds.Dy() {
91+ t.Fatalf("not a partial redraw: all %v, area %v of %v", whole, area, bounds)
92+ }
77 if allocs := testing.AllocsPerRun(50, partial); allocs != 0 {93 if allocs := testing.AllocsPerRun(50, partial); allocs != 0 {
78- t.Errorf("content-damage draws: %.1f allocs/frame, want 0", allocs)94+ t.Errorf("partial redraw draws: %.1f allocs/frame, want 0", allocs)
79 }95 }
80 })96 })
81 }97 }
M internal/core/core.go +3 -2
@@ -588,12 +588,13 @@ func (c *Core) publish(ctx context.Context) error {
588588 if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
589589 scene.DropHints = slices.Clone(d.target.hints)
590590 }
591- // Only the focused output lights the focused window's lines.
592- scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
593591 if sc.mon.ov.open {
594592 // Frame the selection and separate numbered and named row groups.
595593 _, scene.Separators = sc.mon.overviewRows()
596594 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
595+ } else {
596+ // Only the focused output lights the focused window's lines.
597+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
597598 }
598599 // A window alone on screen needs no pulse to show it has the focus.
599600 alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
@@ -588,12 +588,13 @@ func (c *Core) publish(ctx context.Context) error {
588 if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {588 if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
589 scene.DropHints = slices.Clone(d.target.hints)589 scene.DropHints = slices.Clone(d.target.hints)
590 }590 }
591- // Only the focused output lights the focused window's lines.
592- scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
593 if sc.mon.ov.open {591 if sc.mon.ov.open {
594 // Frame the selection and separate numbered and named row groups.592 // Frame the selection and separate numbered and named row groups.
595 _, scene.Separators = sc.mon.overviewRows()593 _, scene.Separators = sc.mon.overviewRows()
596 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)594 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
595+ } else {
596+ // Only the focused output lights the focused window's lines.
597+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
597 }598 }
598 // A window alone on screen needs no pulse to show it has the focus.599 // A window alone on screen needs no pulse to show it has the focus.
599 alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1600 alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
M internal/core/publish_alloc_test.go +75 -23
@@ -8,31 +8,41 @@ import (
88 "github.com/bnema/neferwl/internal/ports"
99 )
1010
11-// publishAllocBudget is the allocations of one animation frame (step) on two
12-// screens of six windows each, with camera and rect motions running. Every
13-// scene sent is freshly built (immutable once sent), and the layouts are
14-// rebuilt for every publish, so the floor is not 0. Measured: 43 before
15-// presizing the scene windows and separators, 36 after.
16-const publishAllocBudget = 36
11+// Allocation budgets of one animation frame (step) on two screens of six
12+// windows each, with camera and rect motions running, and on one screen with
13+// the overview open. Every scene sent is freshly built (immutable once sent),
14+// and the layouts are rebuilt for every publish, so the floor is not 0.
15+// Measured: 43 before presizing the scene windows and separators, 36 after;
16+// 70 with the overview open (its rows and card layouts are rebuilt too).
17+const (
18+ publishAllocBudget = 36
19+ publishOverviewAllocBudget = 70
20+)
1721
18-// TestPublishAllocations pins the cost of publishing an animation frame.
19-func TestPublishAllocations(t *testing.T) {
20- // No Clock port: a mock clock would count its own bookkeeping, and the
21- // fallback timer then is the system one.
22+// publishRig is a Core on the real clock (a mock clock would count its own
23+// bookkeeping; the fallback timer is then the system one) with screens
24+// screens of six windows each. The Slowdown cap keeps every motion running
25+// for seconds, so none settles during a measure even under -race or load.
26+func publishRig(t *testing.T, screens int) *Core {
27+ t.Helper()
2228 var cfg ports.Config
2329 cfg.Keyboard.CmdKey = "super"
2430 cfg.Layout.MaxColumns = 2
2531 cfg.Border.Width = 2
2632 cfg.Animations.On = true
33+ cfg.Animations.Slowdown = 10
2734 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})
2835 if err != nil {
2936 t.Fatal(err)
3037 }
31- c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
32- c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
33- t0 := time.Now()
3438 next := WindowID(1)
39+ for i := range screens {
40+ c.addScreen(ports.OutputInfo{Name: string(rune('A' + i)), Width: 300, Height: 200})
41+ }
3542 for _, sc := range c.screens {
43+ if sc.name() == "" {
44+ continue
45+ }
3646 for range 6 {
3747 sc.mon.AddWindow(next)
3848 next++
@@ -40,9 +50,33 @@ func TestPublishAllocations(t *testing.T) {
4050 sc.arrange()
4151 }
4252 c.refreshShown()
53+ return c
54+}
55+
56+// publishStep is one animation frame whose scene is taken from the channel.
57+func publishStep(t *testing.T, c *Core) func() {
58+ ctx := context.Background()
59+ return func() {
60+ if err := c.step(ctx, nil); err != nil {
61+ t.Fatal(err)
62+ }
63+ select {
64+ case <-c.ch.Scenes:
65+ default:
66+ t.Fatal("no scene published")
67+ }
68+ }
69+}
70+
71+// TestPublishAllocations pins the cost of publishing an animation frame.
72+func TestPublishAllocations(t *testing.T) {
73+ c := publishRig(t, 2)
74+ t0 := time.Now()
4375 for _, sc := range c.screens {
44- m := sc.mon
45- ws := m.Current()
76+ if sc.name() == "" {
77+ continue
78+ }
79+ ws := sc.mon.Current()
4680 ws.motion = c.spring(viewSpring(100, 0), t0)
4781 ws.shift = 100
4882 sc.rects = make(map[WindowID]rectMotion, 6)
@@ -56,15 +90,12 @@ func TestPublishAllocations(t *testing.T) {
5690 }
5791 sc.rectsWS = ws
5892 }
59- ctx := context.Background()
60- step := func() {
61- if err := c.step(ctx, nil); err != nil {
62- t.Fatal(err)
63- }
64- <-c.ch.Scenes
65- }
93+ step := publishStep(t, c)
6694 step()
6795 for _, sc := range c.screens {
96+ if sc.name() == "" {
97+ continue
98+ }
6899 if len(sc.rects) != 6 || !sc.mon.Current().motion.on || len(sc.shown) < 6 {
69100 t.Fatalf("setup: rects %d camera %v shown %d", len(sc.rects), sc.mon.Current().motion.on, len(sc.shown))
70101 }
@@ -72,7 +103,10 @@ func TestPublishAllocations(t *testing.T) {
72103 n := testing.AllocsPerRun(50, step)
73104 t.Logf("allocs per step = %v", n)
74105 for _, sc := range c.screens {
75- if len(sc.rects) == 0 || !sc.mon.Current().motion.on {
106+ if sc.name() == "" {
107+ continue
108+ }
109+ if len(sc.rects) != 6 || !sc.mon.Current().motion.on {
76110 t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))
77111 }
78112 }
@@ -80,3 +114,21 @@ func TestPublishAllocations(t *testing.T) {
80114 t.Errorf("publish allocs per frame = %v, budget %d", n, publishAllocBudget)
81115 }
82116 }
117+
118+// TestPublishOverviewAllocations pins the cost of publishing with the
119+// overview open, which guards the reuse of Core.overviewReal.
120+func TestPublishOverviewAllocations(t *testing.T) {
121+ c := publishRig(t, 1)
122+ sc := c.cur()
123+ sc.mon.ToggleOverview()
124+ step := publishStep(t, c)
125+ step()
126+ if !sc.mon.ov.open || len(c.overviewReal) != 6 {
127+ t.Fatalf("setup: overview %v, real layouts %d", sc.mon.ov.open, len(c.overviewReal))
128+ }
129+ n := testing.AllocsPerRun(50, step)
130+ t.Logf("overview allocs per step = %v", n)
131+ if n > publishOverviewAllocBudget {
132+ t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
133+ }
134+}
@@ -8,31 +8,41 @@ import (
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9 )9 )
10 10
11-// publishAllocBudget is the allocations of one animation frame (step) on two11+// Allocation budgets of one animation frame (step) on two screens of six
12-// screens of six windows each, with camera and rect motions running. Every12+// windows each, with camera and rect motions running, and on one screen with
13-// scene sent is freshly built (immutable once sent), and the layouts are13+// the overview open. Every scene sent is freshly built (immutable once sent),
14-// rebuilt for every publish, so the floor is not 0. Measured: 43 before14+// and the layouts are rebuilt for every publish, so the floor is not 0.
15-// presizing the scene windows and separators, 36 after.15+// Measured: 43 before presizing the scene windows and separators, 36 after;
16-const publishAllocBudget = 3616+// 70 with the overview open (its rows and card layouts are rebuilt too).
17+const (
18+ publishAllocBudget = 36
19+ publishOverviewAllocBudget = 70
20+)
17 21
18-// TestPublishAllocations pins the cost of publishing an animation frame.22+// publishRig is a Core on the real clock (a mock clock would count its own
19-func TestPublishAllocations(t *testing.T) {23+// bookkeeping; the fallback timer is then the system one) with screens
20- // No Clock port: a mock clock would count its own bookkeeping, and the24+// screens of six windows each. The Slowdown cap keeps every motion running
21- // fallback timer then is the system one.25+// for seconds, so none settles during a measure even under -race or load.
26+func publishRig(t *testing.T, screens int) *Core {
27+ t.Helper()
22 var cfg ports.Config28 var cfg ports.Config
23 cfg.Keyboard.CmdKey = "super"29 cfg.Keyboard.CmdKey = "super"
24 cfg.Layout.MaxColumns = 230 cfg.Layout.MaxColumns = 2
25 cfg.Border.Width = 231 cfg.Border.Width = 2
26 cfg.Animations.On = true32 cfg.Animations.On = true
33+ cfg.Animations.Slowdown = 10
27 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})34 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})
28 if err != nil {35 if err != nil {
29 t.Fatal(err)36 t.Fatal(err)
30 }37 }
31- c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
32- c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
33- t0 := time.Now()
34 next := WindowID(1)38 next := WindowID(1)
39+ for i := range screens {
40+ c.addScreen(ports.OutputInfo{Name: string(rune('A' + i)), Width: 300, Height: 200})
41+ }
35 for _, sc := range c.screens {42 for _, sc := range c.screens {
43+ if sc.name() == "" {
44+ continue
45+ }
36 for range 6 {46 for range 6 {
37 sc.mon.AddWindow(next)47 sc.mon.AddWindow(next)
38 next++48 next++
@@ -40,9 +50,33 @@ func TestPublishAllocations(t *testing.T) {
40 sc.arrange()50 sc.arrange()
41 }51 }
42 c.refreshShown()52 c.refreshShown()
53+ return c
54+}
55+
56+// publishStep is one animation frame whose scene is taken from the channel.
57+func publishStep(t *testing.T, c *Core) func() {
58+ ctx := context.Background()
59+ return func() {
60+ if err := c.step(ctx, nil); err != nil {
61+ t.Fatal(err)
62+ }
63+ select {
64+ case <-c.ch.Scenes:
65+ default:
66+ t.Fatal("no scene published")
67+ }
68+ }
69+}
70+
71+// TestPublishAllocations pins the cost of publishing an animation frame.
72+func TestPublishAllocations(t *testing.T) {
73+ c := publishRig(t, 2)
74+ t0 := time.Now()
43 for _, sc := range c.screens {75 for _, sc := range c.screens {
44- m := sc.mon76+ if sc.name() == "" {
45- ws := m.Current()77+ continue
78+ }
79+ ws := sc.mon.Current()
46 ws.motion = c.spring(viewSpring(100, 0), t0)80 ws.motion = c.spring(viewSpring(100, 0), t0)
47 ws.shift = 10081 ws.shift = 100
48 sc.rects = make(map[WindowID]rectMotion, 6)82 sc.rects = make(map[WindowID]rectMotion, 6)
@@ -56,15 +90,12 @@ func TestPublishAllocations(t *testing.T) {
56 }90 }
57 sc.rectsWS = ws91 sc.rectsWS = ws
58 }92 }
59- ctx := context.Background()93+ step := publishStep(t, c)
60- step := func() {
61- if err := c.step(ctx, nil); err != nil {
62- t.Fatal(err)
63- }
64- <-c.ch.Scenes
65- }
66 step()94 step()
67 for _, sc := range c.screens {95 for _, sc := range c.screens {
96+ if sc.name() == "" {
97+ continue
98+ }
68 if len(sc.rects) != 6 || !sc.mon.Current().motion.on || len(sc.shown) < 6 {99 if len(sc.rects) != 6 || !sc.mon.Current().motion.on || len(sc.shown) < 6 {
69 t.Fatalf("setup: rects %d camera %v shown %d", len(sc.rects), sc.mon.Current().motion.on, len(sc.shown))100 t.Fatalf("setup: rects %d camera %v shown %d", len(sc.rects), sc.mon.Current().motion.on, len(sc.shown))
70 }101 }
@@ -72,7 +103,10 @@ func TestPublishAllocations(t *testing.T) {
72 n := testing.AllocsPerRun(50, step)103 n := testing.AllocsPerRun(50, step)
73 t.Logf("allocs per step = %v", n)104 t.Logf("allocs per step = %v", n)
74 for _, sc := range c.screens {105 for _, sc := range c.screens {
75- if len(sc.rects) == 0 || !sc.mon.Current().motion.on {106+ if sc.name() == "" {
107+ continue
108+ }
109+ if len(sc.rects) != 6 || !sc.mon.Current().motion.on {
76 t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))110 t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))
77 }111 }
78 }112 }
@@ -80,3 +114,21 @@ func TestPublishAllocations(t *testing.T) {
80 t.Errorf("publish allocs per frame = %v, budget %d", n, publishAllocBudget)114 t.Errorf("publish allocs per frame = %v, budget %d", n, publishAllocBudget)
81 }115 }
82 }116 }
117+
118+// TestPublishOverviewAllocations pins the cost of publishing with the
119+// overview open, which guards the reuse of Core.overviewReal.
120+func TestPublishOverviewAllocations(t *testing.T) {
121+ c := publishRig(t, 1)
122+ sc := c.cur()
123+ sc.mon.ToggleOverview()
124+ step := publishStep(t, c)
125+ step()
126+ if !sc.mon.ov.open || len(c.overviewReal) != 6 {
127+ t.Fatalf("setup: overview %v, real layouts %d", sc.mon.ov.open, len(c.overviewReal))
128+ }
129+ n := testing.AllocsPerRun(50, step)
130+ t.Logf("overview allocs per step = %v", n)
131+ if n > publishOverviewAllocBudget {
132+ t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
133+ }
134+}
M internal/ports/damage_test.go +20 -0
@@ -35,3 +35,23 @@ func TestDamageSince(t *testing.T) {
3535 t.Fatal("stale history used")
3636 }
3737 }
38+
39+func TestAppendDamageSince(t *testing.T) {
40+ a, b := Rect{X: 1, W: 2, H: 2}, Rect{Y: 5, W: 1, H: 1}
41+ c := SurfaceContent{Seq: 7, DamageHistory: []SeqDamage{{Seq: 6, Rects: []Rect{a}}, {Seq: 7, Rects: []Rect{b}}}}
42+ keep := []Rect{{W: 9}}
43+ if got, ok := c.AppendDamageSince(keep, 5); !ok || !slices.Equal(got, []Rect{{W: 9}, a, b}) {
44+ t.Fatalf("append %v %v", got, ok)
45+ }
46+ // A refused history leaves dst as it was.
47+ c.DamageHistory[1].Full = true
48+ if got, ok := c.AppendDamageSince(keep, 5); ok || !slices.Equal(got, keep) {
49+ t.Fatalf("full change: %v %v", got, ok)
50+ }
51+ // Reusing the slice allocates nothing.
52+ c.DamageHistory[1].Full = false
53+ buf := make([]Rect, 0, 4)
54+ if n := testing.AllocsPerRun(20, func() { buf, _ = c.AppendDamageSince(buf[:0], 5) }); n != 0 {
55+ t.Fatalf("allocs = %v", n)
56+ }
57+}
@@ -35,3 +35,23 @@ func TestDamageSince(t *testing.T) {
35 t.Fatal("stale history used")35 t.Fatal("stale history used")
36 }36 }
37 }37 }
38+
39+func TestAppendDamageSince(t *testing.T) {
40+ a, b := Rect{X: 1, W: 2, H: 2}, Rect{Y: 5, W: 1, H: 1}
41+ c := SurfaceContent{Seq: 7, DamageHistory: []SeqDamage{{Seq: 6, Rects: []Rect{a}}, {Seq: 7, Rects: []Rect{b}}}}
42+ keep := []Rect{{W: 9}}
43+ if got, ok := c.AppendDamageSince(keep, 5); !ok || !slices.Equal(got, []Rect{{W: 9}, a, b}) {
44+ t.Fatalf("append %v %v", got, ok)
45+ }
46+ // A refused history leaves dst as it was.
47+ c.DamageHistory[1].Full = true
48+ if got, ok := c.AppendDamageSince(keep, 5); ok || !slices.Equal(got, keep) {
49+ t.Fatalf("full change: %v %v", got, ok)
50+ }
51+ // Reusing the slice allocates nothing.
52+ c.DamageHistory[1].Full = false
53+ buf := make([]Rect, 0, 4)
54+ if n := testing.AllocsPerRun(20, func() { buf, _ = c.AppendDamageSince(buf[:0], 5) }); n != 0 {
55+ t.Fatalf("allocs = %v", n)
56+ }
57+}
M internal/ports/scene.go +17 -6
@@ -302,24 +302,35 @@ type SeqDamage struct {
302302 // DamageSince is the union of changes after content seq up to this one,
303303 // and false when the history does not reach back to seq (redraw all).
304304 func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {
305+ out, ok := c.AppendDamageSince(nil, seq)
306+ if !ok {
307+ return nil, false
308+ }
309+ return out, true
310+}
311+
312+// AppendDamageSince is DamageSince appending to dst, so a caller reusing
313+// its slice allocates nothing. When the history does not reach back to seq
314+// it returns dst unchanged and false.
315+func (c SurfaceContent) AppendDamageSince(dst []Rect, seq uint64) ([]Rect, bool) {
305316 if seq >= c.Seq {
306- return nil, true
317+ return dst, true
307318 }
308319 h := c.DamageHistory
309320 if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {
310- return nil, false
321+ return dst, false
311322 }
312- var out []Rect
323+ n := len(dst)
313324 for _, d := range h {
314325 if d.Seq <= seq {
315326 continue
316327 }
317328 if d.Full {
318- return nil, false
329+ return dst[:n], false
319330 }
320- out = append(out, d.Rects...)
331+ dst = append(dst, d.Rects...)
321332 }
322- return out, true
333+ return dst, true
323334 }
324335
325336 // Subsurface is a child surface at X, Y logical pixels from the root
@@ -302,24 +302,35 @@ type SeqDamage struct {
302 // DamageSince is the union of changes after content seq up to this one,302 // DamageSince is the union of changes after content seq up to this one,
303 // and false when the history does not reach back to seq (redraw all).303 // and false when the history does not reach back to seq (redraw all).
304 func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {304 func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {
305+ out, ok := c.AppendDamageSince(nil, seq)
306+ if !ok {
307+ return nil, false
308+ }
309+ return out, true
310+}
311+
312+// AppendDamageSince is DamageSince appending to dst, so a caller reusing
313+// its slice allocates nothing. When the history does not reach back to seq
314+// it returns dst unchanged and false.
315+func (c SurfaceContent) AppendDamageSince(dst []Rect, seq uint64) ([]Rect, bool) {
305 if seq >= c.Seq {316 if seq >= c.Seq {
306- return nil, true317+ return dst, true
307 }318 }
308 h := c.DamageHistory319 h := c.DamageHistory
309 if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {320 if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {
310- return nil, false321+ return dst, false
311 }322 }
312- var out []Rect323+ n := len(dst)
313 for _, d := range h {324 for _, d := range h {
314 if d.Seq <= seq {325 if d.Seq <= seq {
315 continue326 continue
316 }327 }
317 if d.Full {328 if d.Full {
318- return nil, false329+ return dst[:n], false
319 }330 }
320- out = append(out, d.Rects...)331+ dst = append(dst, d.Rects...)
321 }332 }
322- return out, true333+ return dst, true
323 }334 }
324 335
325 // Subsurface is a child surface at X, Y logical pixels from the root336 // Subsurface is a child surface at X, Y logical pixels from the root

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 23

Tags 10

↑↓ moveenter openesc close