test(ui): add GPU Renderer tests and TestAllocRendererSteadyFrame
Offscreen pixel readback, release eventfd, pool reuse, resize retirement and the steady-frame allocation guard (measured 244, baseline 244).
- commit
- 28f84a449354a2c4355ea17b3864670e0be115b9
- author
- brice · · 2026-10-01 16:32 UTC
- parent
- 62a7058
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +229 -1
A internal/ui/renderer_gpu_test.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package ui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "slices" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefergui/internal/presentation/session" | |
| 12 | + | "github.com/bnema/nefergui/internal/presentation/vkdevice" | |
| 13 | + | "github.com/bnema/nefergui/internal/text" | |
| 14 | + | "golang.org/x/sys/unix" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // gpuRenderer builds a real Renderer on the render node named by | |
| 18 | + | // NEFERGUI_RENDER_NODE (skips without it) with readback enabled. | |
| 19 | + | func gpuRenderer(t testing.TB) (*Renderer, *session.Target) { | |
| 20 | + | t.Helper() | |
| 21 | + | path := os.Getenv("NEFERGUI_RENDER_NODE") | |
| 22 | + | if path == "" { | |
| 23 | + | t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...") | |
| 24 | + | } | |
| 25 | + | var stat unix.Stat_t | |
| 26 | + | if err := unix.Stat(path, &stat); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev))) | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | mods, err := dev.ExportableModifiers() | |
| 34 | + | dev.Close() | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | var formats []Format | |
| 39 | + | for m := range mods { | |
| 40 | + | formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m}) | |
| 41 | + | } | |
| 42 | + | slices.SortFunc(formats, func(a, b Format) int { return int(a.Modifier>>1) - int(b.Modifier>>1) }) | |
| 43 | + | vf := make([]vkdevice.Format, len(formats)) | |
| 44 | + | for i, f := range formats { | |
| 45 | + | vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier} | |
| 46 | + | } | |
| 47 | + | tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf}) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | tg.EnableReadback() | |
| 52 | + | r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts")) | |
| 53 | + | if err != nil { | |
| 54 | + | _ = tg.Close() | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | t.Cleanup(func() { _ = r.Close() }) | |
| 58 | + | return r, tg | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func readable(t testing.TB, fd int, timeout time.Duration) bool { | |
| 62 | + | t.Helper() | |
| 63 | + | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |
| 64 | + | n, err := unix.Poll(fds, int(timeout.Milliseconds())) | |
| 65 | + | if err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | return n > 0 | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // signalRelease plays the compositor: it signals the buffer's release point. | |
| 72 | + | func signalRelease(t testing.TB, tg *session.Target, out *Output) { | |
| 73 | + | t.Helper() | |
| 74 | + | slot := tg.Slots[out.Buffer] | |
| 75 | + | if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestRendererGPUPixelAndRelease(t *testing.T) { | |
| 81 | + | r, tg := gpuRenderer(t) | |
| 82 | + | var out Output | |
| 83 | + | var m struct{} | |
| 84 | + | view := func(f *Frame, _ *struct{}) { | |
| 85 | + | f.Root().Box(Inline("width:50px;height:50px;background:#336699")) | |
| 86 | + | } | |
| 87 | + | r.Resize(100, 80, 1) | |
| 88 | + | ok, err := r.Render(&out, &m, view) | |
| 89 | + | if err != nil || !ok { | |
| 90 | + | t.Fatalf("render: ok=%v err=%v", ok, err) | |
| 91 | + | } | |
| 92 | + | if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 || | |
| 93 | + | out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 || | |
| 94 | + | out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint { | |
| 95 | + | t.Fatalf("output: %+v", out) | |
| 96 | + | } | |
| 97 | + | img, err := tg.Readback(out.Buffer) | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 } | |
| 102 | + | px := img.NRGBAAt(10, 10) | |
| 103 | + | if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) { | |
| 104 | + | t.Fatalf("pixel inside box = %+v", px) | |
| 105 | + | } | |
| 106 | + | if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 { | |
| 107 | + | t.Fatalf("pixel outside box = %+v", px) | |
| 108 | + | } | |
| 109 | + | // Nothing is released until the compositor signals the point. | |
| 110 | + | if readable(t, out.ReleaseFD, 20*time.Millisecond) { | |
| 111 | + | t.Fatal("release eventfd readable before the point signaled") | |
| 112 | + | } | |
| 113 | + | signalRelease(t, tg, &out) | |
| 114 | + | if !readable(t, out.ReleaseFD, time.Second) { | |
| 115 | + | t.Fatal("release eventfd not readable after the release point signaled") | |
| 116 | + | } | |
| 117 | + | if err = r.Released(out.Buffer); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if readable(t, out.ReleaseFD, 0) { | |
| 121 | + | t.Fatal("release eventfd still readable after Released") | |
| 122 | + | } | |
| 123 | + | // The same buffer comes back with stable descriptors and no re-import. | |
| 124 | + | first := out.Buffer | |
| 125 | + | r.Invalidate() | |
| 126 | + | seen := map[uint64]bool{first: true} | |
| 127 | + | for i := 0; i < 3 && len(seen) < 3; i++ { | |
| 128 | + | r.Invalidate() | |
| 129 | + | ok, err = r.Render(&out, &m, view) | |
| 130 | + | if err != nil || !ok { | |
| 131 | + | t.Fatalf("render %d: ok=%v err=%v", i, ok, err) | |
| 132 | + | } | |
| 133 | + | seen[out.Buffer] = true | |
| 134 | + | if !out.NewBuffer && out.NewTimelines { | |
| 135 | + | t.Fatal("timelines announced for a known buffer") | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | if len(seen) != 3 { | |
| 139 | + | t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen) | |
| 140 | + | } | |
| 141 | + | // All three are compositor owned: no buffer is free, Render must not draw. | |
| 142 | + | r.Invalidate() | |
| 143 | + | if ok, err = r.Render(&out, &m, view); ok || err != nil { | |
| 144 | + | t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestRendererGPUResizeRetiresBuffers(t *testing.T) { | |
| 149 | + | r, tg := gpuRenderer(t) | |
| 150 | + | var out Output | |
| 151 | + | var m struct{} | |
| 152 | + | view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) } | |
| 153 | + | r.Resize(64, 64, 1) | |
| 154 | + | if ok, err := r.Render(&out, &m, view); !ok || err != nil { | |
| 155 | + | t.Fatalf("ok=%v err=%v", ok, err) | |
| 156 | + | } | |
| 157 | + | old := out.Buffer | |
| 158 | + | signalRelease(t, tg, &out) | |
| 159 | + | if err := r.Released(old); err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | r.Resize(96, 64, 1) | |
| 163 | + | if ok, err := r.Render(&out, &m, view); !ok || err != nil { | |
| 164 | + | t.Fatalf("after resize: ok=%v err=%v", ok, err) | |
| 165 | + | } | |
| 166 | + | if out.Width != 96 || !out.NewBuffer || out.Buffer == old { | |
| 167 | + | t.Fatalf("resize output: %+v", out) | |
| 168 | + | } | |
| 169 | + | found := false | |
| 170 | + | for _, rt := range out.Retired { | |
| 171 | + | found = found || rt.Buffer == old | |
| 172 | + | } | |
| 173 | + | if !found { | |
| 174 | + | t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // allocBaselineRenderer is the measured allocation count of one steady | |
| 179 | + | // Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the | |
| 180 | + | // target is 0, the remainder is frame build, layout and prepare. | |
| 181 | + | const allocBaselineRenderer = 244 | |
| 182 | + | ||
| 183 | + | // TestAllocRendererSteadyFrame measures Render with a view whose number | |
| 184 | + | // changes on every frame and an unchanged structure. It runs on the real GPU | |
| 185 | + | // path because testify mocks would allocate. | |
| 186 | + | func TestAllocRendererSteadyFrame(t *testing.T) { | |
| 187 | + | r, tg := gpuRenderer(t) | |
| 188 | + | var out Output | |
| 189 | + | n := 0 | |
| 190 | + | view := func(f *Frame, n *int) { | |
| 191 | + | root := f.Root() | |
| 192 | + | root.Heading("Counter") | |
| 193 | + | root.TextInt("Value: ", int64(*n)) | |
| 194 | + | root.Button("Go", Key("go")) | |
| 195 | + | } | |
| 196 | + | r.Resize(160, 120, 1) | |
| 197 | + | step := func() { | |
| 198 | + | n++ | |
| 199 | + | r.Invalidate() | |
| 200 | + | // A false result can only mean the GPU still works on the previous | |
| 201 | + | // frame of every free buffer; the application retries shortly. | |
| 202 | + | ok := false | |
| 203 | + | for tries := 0; !ok && tries < 1000; tries++ { | |
| 204 | + | var err error | |
| 205 | + | if ok, err = r.Render(&out, &n, view); err != nil { | |
| 206 | + | t.Fatal(err) | |
| 207 | + | } | |
| 208 | + | if !ok { | |
| 209 | + | time.Sleep(time.Millisecond) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | if !ok { | |
| 213 | + | t.Fatal("render never found a ready buffer") | |
| 214 | + | } | |
| 215 | + | slot := tg.Slots[out.Buffer] | |
| 216 | + | _ = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint) | |
| 217 | + | if err := r.Released(out.Buffer); err != nil { | |
| 218 | + | t.Fatal(err) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | for i := 0; i < 20; i++ { // warm up caches, atlas, buffers | |
| 222 | + | step() | |
| 223 | + | } | |
| 224 | + | got := testing.AllocsPerRun(50, step) | |
| 225 | + | t.Logf("allocs per steady Render+Released cycle: %v", got) | |
| 226 | + | if got > allocBaselineRenderer { | |
| 227 | + | t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer) | |
| 228 | + | } | |
| 229 | + | } | |
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package ui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "slices" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/nefergui/internal/presentation/session" | |||
| 12 | + | "github.com/bnema/nefergui/internal/presentation/vkdevice" | |||
| 13 | + | "github.com/bnema/nefergui/internal/text" | |||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // gpuRenderer builds a real Renderer on the render node named by | |||
| 18 | + | // NEFERGUI_RENDER_NODE (skips without it) with readback enabled. | |||
| 19 | + | func gpuRenderer(t testing.TB) (*Renderer, *session.Target) { | |||
| 20 | + | t.Helper() | |||
| 21 | + | path := os.Getenv("NEFERGUI_RENDER_NODE") | |||
| 22 | + | if path == "" { | |||
| 23 | + | t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...") | |||
| 24 | + | } | |||
| 25 | + | var stat unix.Stat_t | |||
| 26 | + | if err := unix.Stat(path, &stat); err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev))) | |||
| 30 | + | if err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | mods, err := dev.ExportableModifiers() | |||
| 34 | + | dev.Close() | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | var formats []Format | |||
| 39 | + | for m := range mods { | |||
| 40 | + | formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m}) | |||
| 41 | + | } | |||
| 42 | + | slices.SortFunc(formats, func(a, b Format) int { return int(a.Modifier>>1) - int(b.Modifier>>1) }) | |||
| 43 | + | vf := make([]vkdevice.Format, len(formats)) | |||
| 44 | + | for i, f := range formats { | |||
| 45 | + | vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier} | |||
| 46 | + | } | |||
| 47 | + | tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf}) | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | tg.EnableReadback() | |||
| 52 | + | r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts")) | |||
| 53 | + | if err != nil { | |||
| 54 | + | _ = tg.Close() | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | t.Cleanup(func() { _ = r.Close() }) | |||
| 58 | + | return r, tg | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func readable(t testing.TB, fd int, timeout time.Duration) bool { | |||
| 62 | + | t.Helper() | |||
| 63 | + | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |||
| 64 | + | n, err := unix.Poll(fds, int(timeout.Milliseconds())) | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | return n > 0 | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // signalRelease plays the compositor: it signals the buffer's release point. | |||
| 72 | + | func signalRelease(t testing.TB, tg *session.Target, out *Output) { | |||
| 73 | + | t.Helper() | |||
| 74 | + | slot := tg.Slots[out.Buffer] | |||
| 75 | + | if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestRendererGPUPixelAndRelease(t *testing.T) { | |||
| 81 | + | r, tg := gpuRenderer(t) | |||
| 82 | + | var out Output | |||
| 83 | + | var m struct{} | |||
| 84 | + | view := func(f *Frame, _ *struct{}) { | |||
| 85 | + | f.Root().Box(Inline("width:50px;height:50px;background:#336699")) | |||
| 86 | + | } | |||
| 87 | + | r.Resize(100, 80, 1) | |||
| 88 | + | ok, err := r.Render(&out, &m, view) | |||
| 89 | + | if err != nil || !ok { | |||
| 90 | + | t.Fatalf("render: ok=%v err=%v", ok, err) | |||
| 91 | + | } | |||
| 92 | + | if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 || | |||
| 93 | + | out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 || | |||
| 94 | + | out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint { | |||
| 95 | + | t.Fatalf("output: %+v", out) | |||
| 96 | + | } | |||
| 97 | + | img, err := tg.Readback(out.Buffer) | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 } | |||
| 102 | + | px := img.NRGBAAt(10, 10) | |||
| 103 | + | if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) { | |||
| 104 | + | t.Fatalf("pixel inside box = %+v", px) | |||
| 105 | + | } | |||
| 106 | + | if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 { | |||
| 107 | + | t.Fatalf("pixel outside box = %+v", px) | |||
| 108 | + | } | |||
| 109 | + | // Nothing is released until the compositor signals the point. | |||
| 110 | + | if readable(t, out.ReleaseFD, 20*time.Millisecond) { | |||
| 111 | + | t.Fatal("release eventfd readable before the point signaled") | |||
| 112 | + | } | |||
| 113 | + | signalRelease(t, tg, &out) | |||
| 114 | + | if !readable(t, out.ReleaseFD, time.Second) { | |||
| 115 | + | t.Fatal("release eventfd not readable after the release point signaled") | |||
| 116 | + | } | |||
| 117 | + | if err = r.Released(out.Buffer); err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | if readable(t, out.ReleaseFD, 0) { | |||
| 121 | + | t.Fatal("release eventfd still readable after Released") | |||
| 122 | + | } | |||
| 123 | + | // The same buffer comes back with stable descriptors and no re-import. | |||
| 124 | + | first := out.Buffer | |||
| 125 | + | r.Invalidate() | |||
| 126 | + | seen := map[uint64]bool{first: true} | |||
| 127 | + | for i := 0; i < 3 && len(seen) < 3; i++ { | |||
| 128 | + | r.Invalidate() | |||
| 129 | + | ok, err = r.Render(&out, &m, view) | |||
| 130 | + | if err != nil || !ok { | |||
| 131 | + | t.Fatalf("render %d: ok=%v err=%v", i, ok, err) | |||
| 132 | + | } | |||
| 133 | + | seen[out.Buffer] = true | |||
| 134 | + | if !out.NewBuffer && out.NewTimelines { | |||
| 135 | + | t.Fatal("timelines announced for a known buffer") | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | if len(seen) != 3 { | |||
| 139 | + | t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen) | |||
| 140 | + | } | |||
| 141 | + | // All three are compositor owned: no buffer is free, Render must not draw. | |||
| 142 | + | r.Invalidate() | |||
| 143 | + | if ok, err = r.Render(&out, &m, view); ok || err != nil { | |||
| 144 | + | t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestRendererGPUResizeRetiresBuffers(t *testing.T) { | |||
| 149 | + | r, tg := gpuRenderer(t) | |||
| 150 | + | var out Output | |||
| 151 | + | var m struct{} | |||
| 152 | + | view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) } | |||
| 153 | + | r.Resize(64, 64, 1) | |||
| 154 | + | if ok, err := r.Render(&out, &m, view); !ok || err != nil { | |||
| 155 | + | t.Fatalf("ok=%v err=%v", ok, err) | |||
| 156 | + | } | |||
| 157 | + | old := out.Buffer | |||
| 158 | + | signalRelease(t, tg, &out) | |||
| 159 | + | if err := r.Released(old); err != nil { | |||
| 160 | + | t.Fatal(err) | |||
| 161 | + | } | |||
| 162 | + | r.Resize(96, 64, 1) | |||
| 163 | + | if ok, err := r.Render(&out, &m, view); !ok || err != nil { | |||
| 164 | + | t.Fatalf("after resize: ok=%v err=%v", ok, err) | |||
| 165 | + | } | |||
| 166 | + | if out.Width != 96 || !out.NewBuffer || out.Buffer == old { | |||
| 167 | + | t.Fatalf("resize output: %+v", out) | |||
| 168 | + | } | |||
| 169 | + | found := false | |||
| 170 | + | for _, rt := range out.Retired { | |||
| 171 | + | found = found || rt.Buffer == old | |||
| 172 | + | } | |||
| 173 | + | if !found { | |||
| 174 | + | t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // allocBaselineRenderer is the measured allocation count of one steady | |||
| 179 | + | // Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the | |||
| 180 | + | // target is 0, the remainder is frame build, layout and prepare. | |||
| 181 | + | const allocBaselineRenderer = 244 | |||
| 182 | + | ||||
| 183 | + | // TestAllocRendererSteadyFrame measures Render with a view whose number | |||
| 184 | + | // changes on every frame and an unchanged structure. It runs on the real GPU | |||
| 185 | + | // path because testify mocks would allocate. | |||
| 186 | + | func TestAllocRendererSteadyFrame(t *testing.T) { | |||
| 187 | + | r, tg := gpuRenderer(t) | |||
| 188 | + | var out Output | |||
| 189 | + | n := 0 | |||
| 190 | + | view := func(f *Frame, n *int) { | |||
| 191 | + | root := f.Root() | |||
| 192 | + | root.Heading("Counter") | |||
| 193 | + | root.TextInt("Value: ", int64(*n)) | |||
| 194 | + | root.Button("Go", Key("go")) | |||
| 195 | + | } | |||
| 196 | + | r.Resize(160, 120, 1) | |||
| 197 | + | step := func() { | |||
| 198 | + | n++ | |||
| 199 | + | r.Invalidate() | |||
| 200 | + | // A false result can only mean the GPU still works on the previous | |||
| 201 | + | // frame of every free buffer; the application retries shortly. | |||
| 202 | + | ok := false | |||
| 203 | + | for tries := 0; !ok && tries < 1000; tries++ { | |||
| 204 | + | var err error | |||
| 205 | + | if ok, err = r.Render(&out, &n, view); err != nil { | |||
| 206 | + | t.Fatal(err) | |||
| 207 | + | } | |||
| 208 | + | if !ok { | |||
| 209 | + | time.Sleep(time.Millisecond) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | if !ok { | |||
| 213 | + | t.Fatal("render never found a ready buffer") | |||
| 214 | + | } | |||
| 215 | + | slot := tg.Slots[out.Buffer] | |||
| 216 | + | _ = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint) | |||
| 217 | + | if err := r.Released(out.Buffer); err != nil { | |||
| 218 | + | t.Fatal(err) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | for i := 0; i < 20; i++ { // warm up caches, atlas, buffers | |||
| 222 | + | step() | |||
| 223 | + | } | |||
| 224 | + | got := testing.AllocsPerRun(50, step) | |||
| 225 | + | t.Logf("allocs per steady Render+Released cycle: %v", got) | |||
| 226 | + | if got > allocBaselineRenderer { | |||
| 227 | + | t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer) | |||
| 228 | + | } | |||
| 229 | + | } | |||
M internal/ui/renderer_test.go +0 -1
| @@ -15,7 +15,6 @@ import ( | |||
| 15 | 15 | type testModel struct { | |
| 16 | 16 | clicks int | |
| 17 | 17 | rects []Rect | |
| 18 | - | inline string | |
| 19 | 18 | } | |
| 20 | 19 | ||
| 21 | 20 | func testView(f *Frame, m *testModel) { | |
| @@ -15,7 +15,6 @@ import ( | |||||
| 15 | type testModel struct { | 15 | type testModel struct { | ||
| 16 | clicks int | 16 | clicks int | ||
| 17 | rects []Rect | 17 | rects []Rect | ||
| 18 | - | inline string | |||
| 19 | } | 18 | } | ||
| 20 | 19 | ||||
| 21 | func testView(f *Frame, m *testModel) { | 20 | func testView(f *Frame, m *testModel) { | ||