v0.7.2 71ebe61

renderer_gpu_test.go RawHistoryFolder

1//go:build linux
2
3package ui
4
5import (
6	"cmp"
7	"os"
8	"slices"
9	"testing"
10	"time"
11
12	"github.com/bnema/nefergui/internal/presentation/session"
13	"github.com/bnema/nefergui/internal/presentation/vkdevice"
14	"github.com/bnema/nefergui/internal/text"
15	"golang.org/x/sys/unix"
16)
17
18// gpuRenderer builds a real Renderer on the render node named by
19// NEFERGUI_RENDER_NODE (skips without it) with readback enabled.
20func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
21	t.Helper()
22	path := os.Getenv("NEFERGUI_RENDER_NODE")
23	if path == "" {
24		t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
25	}
26	var stat unix.Stat_t
27	if err := unix.Stat(path, &stat); err != nil {
28		t.Fatal(err)
29	}
30	dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev)))
31	if err != nil {
32		t.Fatal(err)
33	}
34	mods, err := dev.ExportableModifiers()
35	dev.Close()
36	if err != nil {
37		t.Fatal(err)
38	}
39	var formats []Format
40	for m := range mods {
41		formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
42	}
43	slices.SortFunc(formats, func(a, b Format) int { return cmp.Compare(a.Modifier, b.Modifier) })
44	vf := make([]vkdevice.Format, len(formats))
45	for i, f := range formats {
46		vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
47	}
48	tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf})
49	if err != nil {
50		t.Fatal(err)
51	}
52	tg.EnableReadback()
53	r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
54	if err != nil {
55		_ = tg.Close()
56		t.Fatal(err)
57	}
58	t.Cleanup(func() { _ = r.Close() })
59	return r, tg
60}
61
62func readable(t testing.TB, fd int, timeout time.Duration) bool {
63	t.Helper()
64	fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
65	n, err := unix.Poll(fds, int(timeout.Milliseconds()))
66	if err != nil {
67		t.Fatal(err)
68	}
69	return n > 0
70}
71
72// signalRelease plays the compositor: it signals the buffer's release point.
73func signalRelease(t testing.TB, tg *session.Target, out *Output) {
74	t.Helper()
75	slot := tg.Slots[out.Buffer]
76	if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
77		t.Fatal(err)
78	}
79}
80
81func TestRendererGPUPixelAndRelease(t *testing.T) {
82	r, tg := gpuRenderer(t)
83	var out Output
84	var m struct{}
85	view := func(f *Frame, _ *struct{}) {
86		f.Root().Box(Inline("width:50px;height:50px;background:#336699"))
87	}
88	r.Resize(100, 80, 1)
89	ok, err := r.Render(&out, &m, view)
90	if err != nil || !ok {
91		t.Fatalf("render: ok=%v err=%v", ok, err)
92	}
93	if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 ||
94		out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 ||
95		out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint {
96		t.Fatalf("output: %+v", out)
97	}
98	img, err := tg.Readback(out.Buffer)
99	if err != nil {
100		t.Fatal(err)
101	}
102	near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 }
103	px := img.NRGBAAt(10, 10)
104	if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) {
105		t.Fatalf("pixel inside box = %+v", px)
106	}
107	if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 {
108		t.Fatalf("pixel outside box = %+v", px)
109	}
110	// Nothing is released until the compositor signals the point.
111	if readable(t, out.ReleaseFD, 20*time.Millisecond) {
112		t.Fatal("release eventfd readable before the point signaled")
113	}
114	signalRelease(t, tg, &out)
115	if !readable(t, out.ReleaseFD, time.Second) {
116		t.Fatal("release eventfd not readable after the release point signaled")
117	}
118	if err = r.Released(out.Buffer); err != nil {
119		t.Fatal(err)
120	}
121	if readable(t, out.ReleaseFD, 0) {
122		t.Fatal("release eventfd still readable after Released")
123	}
124	// The same buffer comes back with stable descriptors and no re-import.
125	first := out.Buffer
126	r.Invalidate()
127	seen := map[uint64]bool{first: true}
128	for i := 0; i < 3 && len(seen) < 3; i++ {
129		r.Invalidate()
130		ok, err = r.Render(&out, &m, view)
131		if err != nil || !ok {
132			t.Fatalf("render %d: ok=%v err=%v", i, ok, err)
133		}
134		seen[out.Buffer] = true
135		if !out.NewBuffer && out.NewTimelines {
136			t.Fatal("timelines announced for a known buffer")
137		}
138	}
139	if len(seen) != 3 {
140		t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen)
141	}
142	// All three are compositor owned: no buffer is free, Render must not draw.
143	r.Invalidate()
144	if ok, err = r.Render(&out, &m, view); ok || err != nil {
145		t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err)
146	}
147}
148
149func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
150	r, tg := gpuRenderer(t)
151	var out Output
152	var m struct{}
153	view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
154	r.Resize(64, 64, 1)
155	if ok, err := r.Render(&out, &m, view); !ok || err != nil {
156		t.Fatalf("ok=%v err=%v", ok, err)
157	}
158	old := out.Buffer
159	signalRelease(t, tg, &out)
160	if err := r.Released(old); err != nil {
161		t.Fatal(err)
162	}
163	// Retirement waits for the old frame's GPU work; a late one is reported by
164	// a later Render, so let it finish to keep the test deterministic.
165	if err := tg.Slots[old].Frame.Wait(); err != nil {
166		t.Fatal(err)
167	}
168	r.Resize(96, 64, 1)
169	if ok, err := r.Render(&out, &m, view); !ok || err != nil {
170		t.Fatalf("after resize: ok=%v err=%v", ok, err)
171	}
172	if out.Width != 96 || !out.NewBuffer || out.Buffer == old {
173		t.Fatalf("resize output: %+v", out)
174	}
175	found := false
176	for _, rt := range out.Retired {
177		found = found || rt.Buffer == old
178	}
179	if !found {
180		t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired)
181	}
182}
183
184// allocBaselineRenderer is the measured allocation count of one steady
185// Render+Released cycle (radv); it is lowered with each optimization until it
186// reaches the target of 0.
187const allocBaselineRenderer = 22
188
189// TestAllocRendererSteadyFrame measures Render with a view whose number
190// changes on every frame and an unchanged structure. It runs on the real GPU
191// path because testify mocks would allocate.
192func TestAllocRendererSteadyFrame(t *testing.T) {
193	if raceEnabled {
194		t.Skip("race instrumentation changes allocation counts")
195	}
196	step := steadyFrame(t)
197	got := testing.AllocsPerRun(50, step)
198	t.Logf("allocs per steady Render+Released cycle: %v", got)
199	if got > allocBaselineRenderer {
200		t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
201	}
202}
203
204// BenchmarkRendererSteadyFrame profiles the TestAllocRendererSteadyFrame
205// cycle; it needs NEFERGUI_RENDER_NODE.
206func BenchmarkRendererSteadyFrame(b *testing.B) {
207	step := steadyFrame(b)
208	b.ReportAllocs()
209	for b.Loop() {
210		step()
211	}
212}
213
214// steadyFrame returns one warmed Render+Released cycle for a counter view.
215func steadyFrame(t testing.TB) func() {
216	t.Helper()
217	r, tg := gpuRenderer(t)
218	var out Output
219	n := 0
220	view := func(f *Frame, n *int) {
221		root := f.Root()
222		root.Heading("Counter")
223		root.TextInt("Value: ", int64(*n))
224		root.Button("Go", Key("go"))
225	}
226	r.Resize(160, 120, 1)
227	step := func() {
228		n++
229		r.Invalidate()
230		ok, err := r.Render(&out, &n, view)
231		for tries := 0; err == nil && !ok && r.Pending() && tries < 5000; tries++ {
232			time.Sleep(200 * time.Microsecond)
233			ok, err = r.Render(&out, &n, view)
234		}
235		if err != nil || !ok {
236			t.Fatalf("render: ok=%v err=%v pending=%v", ok, err, r.Pending())
237		}
238		slot := tg.Slots[out.Buffer]
239		if err = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
240			t.Fatal(err)
241		}
242		if err = r.Released(out.Buffer); err != nil {
243			t.Fatal(err)
244		}
245	}
246	for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
247		step()
248	}
249	return step
250}
251
252// A readable eventfd whose point is not signaled (a stale notification) must be
253// drained without spinning, and the live registration must still work.
254func TestRendererGPUStaleReleaseNotification(t *testing.T) {
255	r, tg := gpuRenderer(t)
256	var out Output
257	var m struct{}
258	view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
259	r.Resize(64, 64, 1)
260	if ok, err := r.Render(&out, &m, view); !ok || err != nil {
261		t.Fatalf("ok=%v err=%v", ok, err)
262	}
263	one := [8]byte{1}
264	if _, err := unix.Write(out.ReleaseFD, one[:]); err != nil {
265		t.Fatal(err)
266	}
267	if !readable(t, out.ReleaseFD, time.Second) {
268		t.Fatal("test setup: eventfd not readable")
269	}
270	if err := r.Released(out.Buffer); err != nil {
271		t.Fatal(err)
272	}
273	if readable(t, out.ReleaseFD, 0) {
274		t.Fatal("stale notification left the eventfd readable: the caller would spin")
275	}
276	if tg.Slots[out.Buffer].Buffer.State == "available" {
277		t.Fatal("buffer reused before the compositor released it")
278	}
279	signalRelease(t, tg, &out)
280	if !readable(t, out.ReleaseFD, time.Second) {
281		t.Fatal("live registration lost after a stale notification")
282	}
283	if err := r.Released(out.Buffer); err != nil {
284		t.Fatal(err)
285	}
286	// Released for an unknown or idle buffer is a no-op that still drains.
287	if err := r.Released(999); err != nil {
288		t.Fatal(err)
289	}
290}

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 1

Tags 12

↑↓ moveenter openesc close