detached afb3cdc

perf(core): presize scene windows and separators, pin publish and moving-window draw allocations

commit
afb3cdc28db7091677b0e632a05090262f07d8fd
author
brice · · 2026-10-04 20:03 UTC
committer
brice · · 2026-10-04 20:13 UTC
parent
cca9f08
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +134 -4
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|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|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|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|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/scene_perf_test.go +37 -1
@@ -9,7 +9,10 @@ import (
99 )
1010
1111 // TestRenderSteadyStateAllocations measures the cost of a small stable scene,
12-// on a normal and on a rotated output (orient works in place).
12+// on a normal and on a rotated output (orient works in place), and that
13+// building the draws of a scene with a moving window (a new Seq and window
14+// slice every frame, as an animation publishes) allocates nothing.
15+// TODO(A2): add the partial redraw seeded by sceneDelta once it is merged.
1316 func TestRenderSteadyStateAllocations(t *testing.T) {
1417 for _, tc := range []struct {
1518 name string
@@ -41,6 +44,39 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
4144 } else {
4245 t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
4346 }
47+ // A moving window: scenes are prebuilt (core allocates them, not
48+ // the renderer), each with a fresh Seq, so every frame draws
49+ // everything again. The draw building must not allocate.
50+ var moving [4]ports.Scene
51+ for i := range moving {
52+ moving[i] = ports.Scene{Seq: uint64(100 + i), Transform: tc.transform, Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: i, W: tc.sceneW - 4, H: tc.sceneH - 4}}}}
53+ }
54+ bounds := image.Rect(0, 0, r.width, r.height)
55+ n := 0
56+ frameDraws := func() {
57+ n++
58+ s := moving[n%len(moving)]
59+ _ = r.draws(s, contents, r.frameDamage(r.last, s, bounds))
60+ }
61+ frameDraws()
62+ if allocs := testing.AllocsPerRun(50, frameDraws); allocs != 0 {
63+ t.Errorf("moving window draws: %.1f allocs/frame, want 0", allocs)
64+ }
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
71+ partial := func() {
72+ n++
73+ contents[1] = changed[n%2]
74+ _ = r.draws(same, contents, r.frameDamage(r.last, same, bounds))
75+ }
76+ partial()
77+ if allocs := testing.AllocsPerRun(50, partial); allocs != 0 {
78+ t.Errorf("content-damage draws: %.1f allocs/frame, want 0", allocs)
79+ }
4480 })
4581 }
4682 }
@@ -9,7 +9,10 @@ import (
9 )9 )
10 10
11 // TestRenderSteadyStateAllocations measures the cost of a small stable scene,11 // TestRenderSteadyStateAllocations measures the cost of a small stable scene,
12-// on a normal and on a rotated output (orient works in place).12+// on a normal and on a rotated output (orient works in place), and that
13+// building the draws of a scene with a moving window (a new Seq and window
14+// slice every frame, as an animation publishes) allocates nothing.
15+// TODO(A2): add the partial redraw seeded by sceneDelta once it is merged.
13 func TestRenderSteadyStateAllocations(t *testing.T) {16 func TestRenderSteadyStateAllocations(t *testing.T) {
14 for _, tc := range []struct {17 for _, tc := range []struct {
15 name string18 name string
@@ -41,6 +44,39 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
41 } else {44 } else {
42 t.Logf("small steady-state Render: %.1f allocs/frame", allocs)45 t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
43 }46 }
47+ // A moving window: scenes are prebuilt (core allocates them, not
48+ // the renderer), each with a fresh Seq, so every frame draws
49+ // everything again. The draw building must not allocate.
50+ var moving [4]ports.Scene
51+ for i := range moving {
52+ moving[i] = ports.Scene{Seq: uint64(100 + i), Transform: tc.transform, Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: i, W: tc.sceneW - 4, H: tc.sceneH - 4}}}}
53+ }
54+ bounds := image.Rect(0, 0, r.width, r.height)
55+ n := 0
56+ frameDraws := func() {
57+ n++
58+ s := moving[n%len(moving)]
59+ _ = r.draws(s, contents, r.frameDamage(r.last, s, bounds))
60+ }
61+ frameDraws()
62+ if allocs := testing.AllocsPerRun(50, frameDraws); allocs != 0 {
63+ t.Errorf("moving window draws: %.1f allocs/frame, want 0", allocs)
64+ }
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
71+ partial := func() {
72+ n++
73+ contents[1] = changed[n%2]
74+ _ = r.draws(same, contents, r.frameDamage(r.last, same, bounds))
75+ }
76+ partial()
77+ if allocs := testing.AllocsPerRun(50, partial); allocs != 0 {
78+ t.Errorf("content-damage draws: %.1f allocs/frame, want 0", allocs)
79+ }
44 })80 })
45 }81 }
46 }82 }
M internal/core/core.go +9 -2
@@ -67,6 +67,8 @@ type binding struct {
6767 key string
6868 }
6969 type Core struct {
70+ // overviewReal is publish's scratch map of the real layouts under an open overview.
71+ overviewReal map[WindowID]Placement
7072 security ports.SecurityState
7173 lockSurfaces []ports.LockSurfacePlacement
7274 lockFocus WindowID
@@ -559,13 +561,18 @@ func (c *Core) publish(ctx context.Context) error {
559561 if frame != (Rect{W: o.W, H: o.H}) {
560562 clip = frame
561563 }
562- scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
563564 // layout is what is drawn; settled (same indexes) is where the
564565 // windows are going, and alone sizes the configures.
565566 layout, settled := sc.shown, sc.settledLayout
567+ scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
566568 var real map[WindowID]Placement
567569 if sc.mon.ov.open {
568- real = make(map[WindowID]Placement)
570+ // Local to this screen's build: reused, cleared each time.
571+ if c.overviewReal == nil {
572+ c.overviewReal = make(map[WindowID]Placement)
573+ }
574+ clear(c.overviewReal)
575+ real = c.overviewReal
569576 for w := range sc.mon.all() {
570577 for _, p := range w.Layout() {
571578 real[p.ID] = p
@@ -67,6 +67,8 @@ type binding struct {
67 key string67 key string
68 }68 }
69 type Core struct {69 type Core struct {
70+ // overviewReal is publish's scratch map of the real layouts under an open overview.
71+ overviewReal map[WindowID]Placement
70 security ports.SecurityState72 security ports.SecurityState
71 lockSurfaces []ports.LockSurfacePlacement73 lockSurfaces []ports.LockSurfacePlacement
72 lockFocus WindowID74 lockFocus WindowID
@@ -559,13 +561,18 @@ func (c *Core) publish(ctx context.Context) error {
559 if frame != (Rect{W: o.W, H: o.H}) {561 if frame != (Rect{W: o.W, H: o.H}) {
560 clip = frame562 clip = frame
561 }563 }
562- scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
563 // layout is what is drawn; settled (same indexes) is where the564 // layout is what is drawn; settled (same indexes) is where the
564 // windows are going, and alone sizes the configures.565 // windows are going, and alone sizes the configures.
565 layout, settled := sc.shown, sc.settledLayout566 layout, settled := sc.shown, sc.settledLayout
567+ scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
566 var real map[WindowID]Placement568 var real map[WindowID]Placement
567 if sc.mon.ov.open {569 if sc.mon.ov.open {
568- real = make(map[WindowID]Placement)570+ // Local to this screen's build: reused, cleared each time.
571+ if c.overviewReal == nil {
572+ c.overviewReal = make(map[WindowID]Placement)
573+ }
574+ clear(c.overviewReal)
575+ real = c.overviewReal
569 for w := range sc.mon.all() {576 for w := range sc.mon.all() {
570 for _, p := range w.Layout() {577 for _, p := range w.Layout() {
571 real[p.ID] = p578 real[p.ID] = p
A internal/core/publish_alloc_test.go +82 -0
@@ -0,0 +1,82 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
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
17+
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+ var cfg ports.Config
23+ cfg.Keyboard.CmdKey = "super"
24+ cfg.Layout.MaxColumns = 2
25+ cfg.Border.Width = 2
26+ cfg.Animations.On = true
27+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})
28+ if err != nil {
29+ t.Fatal(err)
30+ }
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)
35+ for _, sc := range c.screens {
36+ for range 6 {
37+ sc.mon.AddWindow(next)
38+ next++
39+ }
40+ sc.arrange()
41+ }
42+ c.refreshShown()
43+ for _, sc := range c.screens {
44+ m := sc.mon
45+ ws := m.Current()
46+ ws.motion = c.spring(viewSpring(100, 0), t0)
47+ ws.shift = 100
48+ sc.rects = make(map[WindowID]rectMotion, 6)
49+ sp := viewSpring(100, 0)
50+ for _, p := range sc.settledLayout {
51+ sc.rects[p.ID] = rectMotion{
52+ x: c.spring(sp, t0), y: c.spring(sp, t0),
53+ w: c.spring(sp, t0), h: c.spring(sp, t0),
54+ dx: 100, dy: 100, dw: 100, dh: 100,
55+ }
56+ }
57+ sc.rectsWS = ws
58+ }
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+ }
66+ step()
67+ for _, sc := range c.screens {
68+ 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))
70+ }
71+ }
72+ n := testing.AllocsPerRun(50, step)
73+ t.Logf("allocs per step = %v", n)
74+ for _, sc := range c.screens {
75+ if len(sc.rects) == 0 || !sc.mon.Current().motion.on {
76+ t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))
77+ }
78+ }
79+ if n > publishAllocBudget {
80+ t.Errorf("publish allocs per frame = %v, budget %d", n, publishAllocBudget)
81+ }
82+}
@@ -0,0 +1,82 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
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
17+
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+ var cfg ports.Config
23+ cfg.Keyboard.CmdKey = "super"
24+ cfg.Layout.MaxColumns = 2
25+ cfg.Border.Width = 2
26+ cfg.Animations.On = true
27+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})
28+ if err != nil {
29+ t.Fatal(err)
30+ }
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)
35+ for _, sc := range c.screens {
36+ for range 6 {
37+ sc.mon.AddWindow(next)
38+ next++
39+ }
40+ sc.arrange()
41+ }
42+ c.refreshShown()
43+ for _, sc := range c.screens {
44+ m := sc.mon
45+ ws := m.Current()
46+ ws.motion = c.spring(viewSpring(100, 0), t0)
47+ ws.shift = 100
48+ sc.rects = make(map[WindowID]rectMotion, 6)
49+ sp := viewSpring(100, 0)
50+ for _, p := range sc.settledLayout {
51+ sc.rects[p.ID] = rectMotion{
52+ x: c.spring(sp, t0), y: c.spring(sp, t0),
53+ w: c.spring(sp, t0), h: c.spring(sp, t0),
54+ dx: 100, dy: 100, dw: 100, dh: 100,
55+ }
56+ }
57+ sc.rectsWS = ws
58+ }
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+ }
66+ step()
67+ for _, sc := range c.screens {
68+ 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))
70+ }
71+ }
72+ n := testing.AllocsPerRun(50, step)
73+ t.Logf("allocs per step = %v", n)
74+ for _, sc := range c.screens {
75+ if len(sc.rects) == 0 || !sc.mon.Current().motion.on {
76+ t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))
77+ }
78+ }
79+ if n > publishAllocBudget {
80+ t.Errorf("publish allocs per frame = %v, budget %d", n, publishAllocBudget)
81+ }
82+}
M internal/core/separators.go +5 -0
@@ -38,6 +38,11 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
3838 if p.Floating {
3939 id = p.ID
4040 }
41+ if out == nil {
42+ // One line per side of every tile, and the focused one's four
43+ // lit lines: the common case grows no further.
44+ out = make([]ports.Separator, 0, 4*len(ps)+4)
45+ }
4146 out = append(out, ports.Separator{Rect: r, Active: active, Window: id})
4247 }
4348 var focused *Placement
@@ -38,6 +38,11 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
38 if p.Floating {38 if p.Floating {
39 id = p.ID39 id = p.ID
40 }40 }
41+ if out == nil {
42+ // One line per side of every tile, and the focused one's four
43+ // lit lines: the common case grows no further.
44+ out = make([]ports.Separator, 0, 4*len(ps)+4)
45+ }
41 out = append(out, ports.Separator{Rect: r, Active: active, Window: id})46 out = append(out, ports.Separator{Rect: r, Active: active, Window: id})
42 }47 }
43 var focused *Placement48 var focused *Placement

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 26

Tags 10

↑↓ moveenter openesc close